diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ebe4992..a007141 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -7,6 +7,7 @@ import Logo from "./brand/Logo"; import ThemeToggle from "./ThemeToggle"; import NotificationCenter from "./notifications/NotificationCenter"; import WalletButton from "./WalletButton"; +import LoyaltyDrawer from "./loyalty/LoyaltyDrawer"; import { buttonClasses } from "./ui/Button"; const navLinks = [ @@ -33,7 +34,9 @@ export default function Navbar() { ))} -
+ + +
@@ -45,7 +48,7 @@ export default function Navbar() {
-
+
+ + {open ? ( +
+ +
+ +
+
+

Available Guild Tokens

+

{state.balance.toLocaleString()} GWT

+

Preview balance. Live token data will be connected when the loyalty API is available.

+
+ + {state.status === "browsing" ? ( +
+

Redeem rewards

+
+ {LOYALTY_REWARDS.map((reward) => { + const affordable = canRedeem(state.balance, reward); + return ; + })} +
+
+ ) : null} + + {state.status === "confirming" && state.selectedReward ? dispatch({ type: "CANCEL" })} onConfirm={redeem} /> : null} + {state.status === "redeeming" ? : null} + {state.status === "success" && state.selectedReward ? dispatch({ type: "DONE" })} /> : null} + {state.status === "failed" ? dispatch({ type: "CANCEL" })} /> : null} +
+
+
+ ) : null} + + ); +} + +function Confirmation({ rewardName, cost, blocked, blockedMessage, onCancel, onConfirm }: { rewardName: string; cost: number; blocked: boolean; blockedMessage: string; onCancel: () => void; onConfirm: () => void }) { + return

Confirm redemption

Redeem {cost} GWT for {rewardName}?

{blocked ?

{blockedMessage}

: null}
; +} + +function Status({ title, detail, error = false, actionLabel, onAction }: { title: string; detail: string; error?: boolean; actionLabel?: string; onAction?: () => void }) { + return

{title}

{detail}

{actionLabel && onAction ? : null}
; +} diff --git a/src/lib/loyalty.ts b/src/lib/loyalty.ts new file mode 100644 index 0000000..9dbc6dc --- /dev/null +++ b/src/lib/loyalty.ts @@ -0,0 +1,86 @@ +/** + * Loyalty redemption UI state. The backend does not yet expose the loyalty + * contract, so `redeemReward` is an explicit preview adapter that can be + * replaced without changing the drawer or its state transitions. + */ + +export interface LoyaltyReward { + id: string; + name: string; + description: string; + cost: number; +} + +export const LOYALTY_REWARDS: LoyaltyReward[] = [ + { id: "booking-credit", name: "Booking credit", description: "Take 10% off your next booking.", cost: 500 }, + { id: "priority-support", name: "Priority support", description: "Get priority help with your next request.", cost: 800 }, + { id: "fee-waiver", name: "Fee waiver", description: "Waive the platform fee on one completed booking.", cost: 1200 }, +]; + +export type LoyaltyRedemptionStatus = "browsing" | "confirming" | "redeeming" | "success" | "failed"; + +export interface LoyaltyState { + balance: number; + selectedReward: LoyaltyReward | null; + status: LoyaltyRedemptionStatus; + reference: string | null; + error: string | null; +} + +export const initialLoyaltyState: LoyaltyState = { + balance: 1240, + selectedReward: null, + status: "browsing", + reference: null, + error: null, +}; + +export type LoyaltyEvent = + | { type: "SELECT"; reward: LoyaltyReward } + | { type: "CANCEL" } + | { type: "REDEEM_START" } + | { type: "REDEEM_SUCCESS"; reference: string } + | { type: "REDEEM_ERROR"; error: string } + | { type: "DONE" }; + +export function canRedeem(balance: number, reward: LoyaltyReward): boolean { + return balance >= reward.cost; +} + +export function loyaltyReducer(state: LoyaltyState, event: LoyaltyEvent): LoyaltyState { + switch (event.type) { + case "SELECT": + if (!canRedeem(state.balance, event.reward) || state.status === "redeeming") return state; + return { ...state, selectedReward: event.reward, status: "confirming", error: null, reference: null }; + case "CANCEL": + if (state.status !== "confirming" && state.status !== "failed") return state; + return { ...state, selectedReward: null, status: "browsing", error: null }; + case "REDEEM_START": + if (state.status !== "confirming" || !state.selectedReward) return state; + return { ...state, status: "redeeming", error: null }; + case "REDEEM_SUCCESS": + if (state.status !== "redeeming" || !state.selectedReward) return state; + return { + balance: state.balance - state.selectedReward.cost, + selectedReward: state.selectedReward, + status: "success", + reference: event.reference, + error: null, + }; + case "REDEEM_ERROR": + if (state.status !== "redeeming") return state; + return { ...state, status: "failed", error: event.error }; + case "DONE": + if (state.status !== "success") return state; + return { ...state, selectedReward: null, status: "browsing", reference: null }; + default: + return state; + } +} + +export async function redeemReward(reward: LoyaltyReward, walletAddress: string): Promise<{ reference: string }> { + void reward; + void walletAddress; + await new Promise((resolve) => setTimeout(resolve, 800)); + return { reference: `LOY-${Date.now().toString(36).toUpperCase()}` }; +} diff --git a/src/lib/test/loyalty.test.ts b/src/lib/test/loyalty.test.ts new file mode 100644 index 0000000..da3b9c0 --- /dev/null +++ b/src/lib/test/loyalty.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { LOYALTY_REWARDS, initialLoyaltyState, loyaltyReducer } from "../loyalty"; + +describe("loyaltyReducer", () => { + const reward = LOYALTY_REWARDS[0]; + + it("moves through confirmation, pending, and success while updating the balance", () => { + const confirming = loyaltyReducer(initialLoyaltyState, { type: "SELECT", reward }); + const redeeming = loyaltyReducer(confirming, { type: "REDEEM_START" }); + const success = loyaltyReducer(redeeming, { type: "REDEEM_SUCCESS", reference: "LOY-1" }); + + expect(confirming.status).toBe("confirming"); + expect(redeeming.status).toBe("redeeming"); + expect(success).toMatchObject({ status: "success", balance: 740, reference: "LOY-1" }); + }); + + it("does not let an unaffordable reward enter confirmation", () => { + const state = loyaltyReducer({ ...initialLoyaltyState, balance: 100 }, { + type: "SELECT", + reward, + }); + + expect(state).toEqual({ ...initialLoyaltyState, balance: 100 }); + }); + + it("retains an error for retry and does not deduct points on failure", () => { + const confirming = loyaltyReducer(initialLoyaltyState, { type: "SELECT", reward }); + const redeeming = loyaltyReducer(confirming, { type: "REDEEM_START" }); + const failed = loyaltyReducer(redeeming, { type: "REDEEM_ERROR", error: "Service unavailable" }); + + expect(failed).toMatchObject({ status: "failed", balance: 1240, error: "Service unavailable" }); + expect(loyaltyReducer(failed, { type: "CANCEL" }).status).toBe("browsing"); + }); +});