Files
hope-events/frontend/src/components/shared/CouponCodeField.tsx
T
joshuaandClaude Opus 5.5 e9d1b1fb24 Accessibility pass, WCAG 2.2 AA (1.50.0)
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>
2026-10-28 15:41:19 +02:00

141 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}