Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -33,7 +34,9 @@ export default function Navbar() {
))}
</nav>

<div className="ml-auto hidden items-center gap-2 sm:flex">
<LoyaltyDrawer />

<div className="hidden items-center gap-2 sm:flex">
<ThemeToggle />
<NotificationCenter />
<WalletButton />
Expand All @@ -45,7 +48,7 @@ export default function Navbar() {
</Link>
</div>

<div className="ml-auto flex items-center gap-2 sm:hidden">
<div className="flex items-center gap-2 sm:hidden">
<ThemeToggle />
<NotificationCenter />
<button
Expand Down
156 changes: 156 additions & 0 deletions src/components/loyalty/LoyaltyDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"use client";

import { useEffect, useReducer, useRef, useState } from "react";
import { HiGift, HiX } from "react-icons/hi";
import { useNotifications } from "@/components/notifications/useNotifications";
import { useWallet } from "@/components/wallet/WalletProvider";
import { errorNotification, pendingNotification, successNotification } from "@/lib/notifications";
import {
canRedeem,
initialLoyaltyState,
LOYALTY_REWARDS,
loyaltyReducer,
redeemReward,
} from "@/lib/loyalty";
import Button, { buttonClasses } from "@/components/ui/Button";

const FOCUSABLE = 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';

export default function LoyaltyDrawer() {
const [open, setOpen] = useState(false);
const [state, dispatch] = useReducer(loyaltyReducer, initialLoyaltyState);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const { address, isWrongNetwork } = useWallet();
const { addNotification } = useNotifications();

const close = () => {
if (state.status === "redeeming") return;
setOpen(false);
};

useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
const trigger = triggerRef.current;
document.body.style.overflow = "hidden";
panelRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (state.status !== "redeeming") setOpen(false);
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};

document.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", onKeyDown);
trigger?.focus();
};
}, [open, state.status]);

const redeem = async () => {
if (!state.selectedReward || !address || isWrongNetwork) return;
dispatch({ type: "REDEEM_START" });
addNotification(pendingNotification("Redeeming reward", "Your reward redemption is being processed."));
try {
const result = await redeemReward(state.selectedReward, address);
dispatch({ type: "REDEEM_SUCCESS", reference: result.reference });
addNotification(successNotification("Reward redeemed", `${state.selectedReward.name} is ready to use.`));
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to redeem this reward. Please try again.";
dispatch({ type: "REDEEM_ERROR", error: message });
addNotification(errorNotification("Redemption failed", message));
}
};

return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className={buttonClasses("ghost", "sm", "px-3")}
aria-haspopup="dialog"
aria-expanded={open}
>
<HiGift className="text-lg text-gold-deep" aria-hidden="true" />
<span className="hidden lg:inline">Rewards</span>
</button>

{open ? (
<div className="fixed inset-0 z-[60]">
<button type="button" className="absolute inset-0 cursor-default bg-navy-ink/45" aria-label="Close rewards drawer" onClick={close} />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="loyalty-title"
className="absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-surface shadow-float"
>
<div className="flex items-center justify-between border-b border-line px-6 py-5">
<div>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-gold-deep">Guild rewards</p>
<h2 id="loyalty-title" className="mt-1 text-xl font-bold text-ink">Your loyalty balance</h2>
</div>
<button type="button" onClick={close} className="rounded-lg p-2 text-muted hover:bg-sand" aria-label="Close rewards drawer">
<HiX className="text-2xl" />
</button>
</div>

<div className="flex-1 overflow-y-auto p-6">
<div className="rounded-2xl bg-navy p-5 text-white">
<p className="text-sm text-white/70">Available Guild Tokens</p>
<p className="mt-1 text-4xl font-bold">{state.balance.toLocaleString()} <span className="text-lg text-gold-2">GWT</span></p>
<p className="mt-3 text-xs text-white/65">Preview balance. Live token data will be connected when the loyalty API is available.</p>
</div>

{state.status === "browsing" ? (
<div className="mt-7">
<h3 className="font-bold text-ink">Redeem rewards</h3>
<div className="mt-3 space-y-3">
{LOYALTY_REWARDS.map((reward) => {
const affordable = canRedeem(state.balance, reward);
return <button key={reward.id} type="button" disabled={!affordable} onClick={() => dispatch({ type: "SELECT", reward })} className="w-full rounded-xl border border-line p-4 text-left transition hover:border-gold disabled:cursor-not-allowed disabled:opacity-50">
<div className="flex justify-between gap-4"><span className="font-semibold text-ink">{reward.name}</span><span className="shrink-0 font-bold text-gold-deep">{reward.cost} GWT</span></div>
<p className="mt-1 text-sm text-muted">{reward.description}</p>
{!affordable ? <p className="mt-2 text-xs font-semibold text-err">Need {(reward.cost - state.balance).toLocaleString()} more GWT</p> : null}
</button>;
})}
</div>
</div>
) : null}

{state.status === "confirming" && state.selectedReward ? <Confirmation rewardName={state.selectedReward.name} cost={state.selectedReward.cost} blocked={!address || isWrongNetwork} blockedMessage={!address ? "Connect your wallet to redeem rewards." : "Switch to the required Stellar network to redeem."} onCancel={() => dispatch({ type: "CANCEL" })} onConfirm={redeem} /> : null}
{state.status === "redeeming" ? <Status title="Redeeming your reward" detail="Please keep this drawer open while we confirm your redemption." /> : null}
{state.status === "success" && state.selectedReward ? <Status title="Reward redeemed" detail={`${state.selectedReward.name} was redeemed. Reference: ${state.reference}`} actionLabel="Done" onAction={() => dispatch({ type: "DONE" })} /> : null}
{state.status === "failed" ? <Status title="Redemption failed" detail={state.error ?? "Please try again."} error actionLabel="Back to rewards" onAction={() => dispatch({ type: "CANCEL" })} /> : null}
</div>
</div>
</div>
) : null}
</>
);
}

function Confirmation({ rewardName, cost, blocked, blockedMessage, onCancel, onConfirm }: { rewardName: string; cost: number; blocked: boolean; blockedMessage: string; onCancel: () => void; onConfirm: () => void }) {
return <div className="mt-7 rounded-2xl border border-gold/60 bg-sand p-5"><h3 className="text-lg font-bold text-ink">Confirm redemption</h3><p className="mt-2 text-sm text-muted">Redeem <strong className="text-ink">{cost} GWT</strong> for {rewardName}?</p>{blocked ? <p className="mt-3 text-sm font-medium text-err">{blockedMessage}</p> : null}<div className="mt-5 flex gap-3"><Button variant="outline" onClick={onCancel}>Cancel</Button><Button variant="gold" disabled={blocked} onClick={onConfirm}>Redeem reward</Button></div></div>;
}

function Status({ title, detail, error = false, actionLabel, onAction }: { title: string; detail: string; error?: boolean; actionLabel?: string; onAction?: () => void }) {
return <div className={`mt-7 rounded-2xl border p-5 ${error ? "border-err/40 bg-err/5" : "border-ok/40 bg-ok/5"}`} aria-live="polite"><h3 className={`text-lg font-bold ${error ? "text-err" : "text-ok"}`}>{title}</h3><p className="mt-2 text-sm text-muted">{detail}</p>{actionLabel && onAction ? <Button className="mt-5" variant={error ? "outline" : "primary"} onClick={onAction}>{actionLabel}</Button> : null}</div>;
}
86 changes: 86 additions & 0 deletions src/lib/loyalty.ts
Original file line number Diff line number Diff line change
@@ -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()}` };
}
34 changes: 34 additions & 0 deletions src/lib/test/loyalty.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading