Public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labels tied to fields, named quantity buttons, announced errors, ModalOverlay popups (focus trap, Escape, focus return), contrast and underlined in-text links, reduced motion. Branding warns about a primary colour too light for white text and offers a darker shade. axe-core and keyboard checks in e2e/accessibility.spec.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useRef, useState } from "react";
|
||
import { TicketPercent, X } from "lucide-react";
|
||
import { apiFetch, ApiError } from "@/lib/api";
|
||
|
||
export type BasketItem = { eventOptionId: string; variantId?: string | null; quantity: number };
|
||
|
||
export type AppliedCoupon = {
|
||
code: string;
|
||
summary: string;
|
||
discount: number;
|
||
subtotal: number;
|
||
total: number;
|
||
};
|
||
|
||
/**
|
||
* "Have a code?" field. Checks the code against the current basket with
|
||
* POST /api/coupons/validate and reports the result through `onChange` — the parent
|
||
* sends `applied.code` as `couponCode` when it registers. Re-checks automatically when
|
||
* the basket changes, since the discount (or whether the code applies at all) can change.
|
||
*
|
||
* Staff checking on someone's behalf pass `forUserId` (the attendee), or `null` for a
|
||
* walk-in with no account yet.
|
||
*/
|
||
export function CouponCodeField({
|
||
eventId,
|
||
basket,
|
||
token,
|
||
forUserId,
|
||
applied,
|
||
onChange,
|
||
compact = false,
|
||
}: {
|
||
eventId: string;
|
||
basket: BasketItem[];
|
||
token?: string | null;
|
||
forUserId?: string | null;
|
||
applied: AppliedCoupon | null;
|
||
onChange: (applied: AppliedCoupon | null) => void;
|
||
compact?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(!!applied);
|
||
const [code, setCode] = useState(applied?.code || "");
|
||
const [checking, setChecking] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const items = basket.filter(b => b.quantity > 0);
|
||
const basketKey = JSON.stringify(items);
|
||
const lastKey = useRef(basketKey);
|
||
|
||
const check = async (value: string) => {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return;
|
||
if (items.length === 0) { setError("Choose your tickets first."); return; }
|
||
setChecking(true);
|
||
setError(null);
|
||
try {
|
||
const body: Record<string, unknown> = { code: trimmed, eventId, options: items };
|
||
if (forUserId !== undefined) body.forUserId = forUserId;
|
||
const res = await apiFetch<AppliedCoupon & { valid: boolean }>("/api/coupons/validate", { method: "POST", authToken: token || undefined, body });
|
||
onChange({ code: res.code, summary: res.summary, discount: res.discount, subtotal: res.subtotal, total: res.total });
|
||
} catch (e) {
|
||
onChange(null);
|
||
setError(e instanceof ApiError ? e.message : "Couldn't check that code. Please try again.");
|
||
} finally {
|
||
setChecking(false);
|
||
}
|
||
};
|
||
|
||
// Basket changed after a code was applied: check it again for the new items.
|
||
useEffect(() => {
|
||
if (lastKey.current === basketKey) return;
|
||
lastKey.current = basketKey;
|
||
if (applied) check(applied.code);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [basketKey]);
|
||
|
||
// Staff switched attendee: restricted codes depend on who it's for.
|
||
useEffect(() => {
|
||
if (applied) check(applied.code);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [forUserId]);
|
||
|
||
const remove = () => {
|
||
onChange(null);
|
||
setCode("");
|
||
setError(null);
|
||
};
|
||
|
||
if (!open) {
|
||
return (
|
||
<button type="button" onClick={() => setOpen(true)} className="inline-flex items-center gap-1.5 text-sm text-brand-600 hover:underline">
|
||
<TicketPercent className="w-4 h-4" /> Have a code?
|
||
</button>
|
||
);
|
||
}
|
||
|
||
if (applied) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm">
|
||
<div className="flex items-center gap-2 text-green-800">
|
||
<TicketPercent className="w-4 h-4 shrink-0" />
|
||
<span>
|
||
<span className="font-mono font-semibold">{applied.code}</span> — {applied.summary}
|
||
{applied.discount > 0 && <span className="font-medium"> (−R {applied.discount.toFixed(2)})</span>}
|
||
</span>
|
||
</div>
|
||
<button type="button" onClick={remove} className="text-green-800 hover:text-green-950" aria-label="Remove code">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{!compact && <p aria-hidden="true" className="block text-sm font-medium text-gray-700 mb-1">Discount code</p>}
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={code}
|
||
onChange={e => { setCode(e.target.value.toUpperCase()); setError(null); }}
|
||
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); check(code); } }}
|
||
placeholder="Enter code"
|
||
className="flex-1 min-w-0 border rounded-lg px-3 py-2 text-sm font-mono uppercase focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||
aria-label="Discount code"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => check(code)}
|
||
disabled={checking || !code.trim()}
|
||
className="px-4 py-2 rounded-lg border border-brand-600 text-brand-700 text-sm font-medium hover:bg-brand-50 disabled:opacity-50"
|
||
>
|
||
{checking ? "Checking…" : "Apply"}
|
||
</button>
|
||
</div>
|
||
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|