Redesign the Coupon codes page to match Bundle deals

Cards instead of the table, phone-friendly sheets for the form and the uses list,
inactive codes hidden by default, and the events picker shows upcoming events only
(with search and a Show past events option).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 16:36:16 +02:00
co-authored by Claude Opus 5.5
parent 4a492a8b16
commit e7969429b6
2 changed files with 380 additions and 285 deletions
@@ -5,8 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
@@ -17,6 +16,7 @@ type EventLite = {
id: string;
title: string;
startDate: string;
endDate?: string;
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
};
@@ -122,17 +122,19 @@ function bodyFromDraft(d: Draft) {
};
}
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
function couponStatus(c: Coupon): { label: string; cls: string } {
type Status = { key: "active" | "scheduled" | "off" | "expired" | "used"; label: string; cls: string };
function couponStatus(c: Coupon): Status {
const now = Date.now();
if (!c.active) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
if (c.maxUses && c.uses >= c.maxUses) return { label: "Used up", cls: "bg-gray-100 text-gray-600" };
return { label: "Active", cls: "bg-green-100 text-green-800" };
if (!c.active) return { key: "off", label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { key: "expired", label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.maxUses && c.uses >= c.maxUses) return { key: "used", label: "Used up", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { key: "scheduled", label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
return { key: "active", label: "Active", cls: "bg-green-100 text-green-800" };
}
export default function CouponsPage() {
@@ -151,7 +153,7 @@ export default function CouponsPage() {
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [showOff, setShowOff] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
@@ -175,10 +177,17 @@ export default function CouponsPage() {
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
// Codes that can still be used first; off, expired and used-up ones only when asked for.
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return coupons.filter(c => (showOff || c.active) && (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q)));
}, [coupons, query, showOff]);
const order: Record<Status["key"], number> = { active: 0, scheduled: 1, used: 2, expired: 3, off: 4 };
return coupons
.map(c => ({ c, status: couponStatus(c) }))
.filter(({ c, status }) => (showInactive || status.key === "active" || status.key === "scheduled")
&& (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q) || c.events.some(e => e.event.title.toLowerCase().includes(q))))
.sort((a, b) => order[a.status.key] - order[b.status.key]);
}, [coupons, query, showInactive]);
const hiddenCount = coupons.filter(c => !["active", "scheduled"].includes(couponStatus(c).key)).length;
const toggleActive = async (c: Coupon) => {
try {
@@ -199,9 +208,11 @@ export default function CouponsPage() {
}
};
const startNew = () => setEditing({ id: null, draft: blankDraft() });
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<TicketPercent className="w-5 h-5 text-brand-600" />
@@ -214,8 +225,8 @@ export default function CouponsPage() {
</div>
{canView && (
<button
onClick={() => setEditing({ id: null, draft: blankDraft() })}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700"
onClick={startNew}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 w-full sm:w-auto"
>
<Plus className="w-4 h-4" /> New code
</button>
@@ -230,72 +241,46 @@ export default function CouponsPage() {
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="relative w-full sm:w-64">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative w-full sm:w-72">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Search codes" value={query} onChange={e => setQuery(e.target.value)} />
<input className={`${inputCls} pl-8`} placeholder="Search codes or events" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={showOff} onChange={e => setShowOff(e.target.checked)} /> Show codes that are off
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} />
Show codes that are off, expired or used up{hiddenCount > 0 ? ` (${hiddenCount})` : ""}
</label>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Events</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Valid</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map(c => {
const status = couponStatus(c);
return (
<TableRow key={c.id}>
<TableCell>
<div className="font-mono font-semibold text-gray-900">{c.code}</div>
{c.description && <div className="text-xs text-gray-500">{c.description}</div>}
{(c.restrictRoles.length > 0 || c.allowedUsers.length > 0 || c.restrictDomain) && (
<div className="text-xs text-amber-700 mt-0.5">
Restricted: {c.allowedUsers.length ? `${c.allowedUsers.length} person${c.allowedUsers.length === 1 ? "" : "s"}` : c.restrictRoles.length ? c.restrictRoles.join(", ") : `@${c.restrictDomain}`}
</div>
)}
</TableCell>
<TableCell>
<div>{c.summary}</div>
{c.options.length > 0 && <div className="text-xs text-gray-500">{c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</div>}
</TableCell>
<TableCell className="text-sm">{c.events.length ? c.events.map(e => e.event.title).join(", ") : <span className="text-gray-500">All events</span>}</TableCell>
<TableCell className="text-sm">
{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}
<div className="text-xs text-gray-500">{fmtRand(c.totalDiscount)} given</div>
</TableCell>
<TableCell className="text-xs text-gray-600">
{c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end"}` : "Always"}
</TableCell>
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
<TableCell className="text-right whitespace-nowrap">
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => openUses(c)}>Uses</button>
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}>Edit</button>
<button className={`px-2 py-1 text-xs rounded text-white ${c.active ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(c)}>
{c.active ? "Turn off" : "Turn on"}
</button>
</TableCell>
</TableRow>
);
})}
{!fetching && visible.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">No coupon codes yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
{!fetching && visible.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center mx-auto mb-3">
<TicketPercent className="w-6 h-6 text-brand-600" />
</div>
<p className="text-sm font-medium text-gray-900">{coupons.length === 0 ? "No coupon codes yet" : "No codes match"}</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. &quot;STAFF50&quot;: 50% off for staff, or &quot;WELCOME20&quot;: R20 off for first-time visitors.</p>
{coupons.length === 0 && (
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Create a code
</button>
)}
</div>
)}
{visible.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{visible.map(({ c, status }) => (
<CouponCard
key={c.id}
coupon={c}
status={status}
onUses={() => openUses(c)}
onEdit={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}
onToggle={() => toggleActive(c)}
/>
))}
</div>
)}
</div>
)}
@@ -311,47 +296,119 @@ export default function CouponsPage() {
)}
{viewing && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setViewing(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">Uses of <span className="font-mono">{viewing.coupon.code}</span></h2>
<button onClick={() => setViewing(null)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto">
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<Table>
<TableHeader>
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
</TableHeader>
<TableBody>
{viewing.redemptions.map(r => (
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
<TableCell>
<div className="text-sm">{r.user.name}</div>
<div className="text-xs text-gray-500">{r.user.email}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</TableCell>
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
<TableCell className="text-sm">
{fmtRand(r.discountAmount)}
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</TableCell>
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
<Sheet title={<>Uses of <span className="font-mono">{viewing.coupon.code}</span></>} onClose={() => setViewing(null)}>
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<ul className="divide-y">
{viewing.redemptions.map(r => (
<li key={r.id} className={`py-2.5 flex items-start justify-between gap-3 ${r.releasedAt ? "opacity-60" : ""}`}>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{r.user.name}</div>
<div className="text-xs text-gray-500 truncate">{r.user.email}</div>
<div className="text-xs text-gray-500">{r.registration.event.title} · {fmtDate(r.createdAt)}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</div>
<div className="text-right shrink-0">
<div className="text-sm font-semibold">{fmtRand(r.discountAmount)}</div>
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</div>
</li>
))}
</ul>
)}
</Sheet>
)}
</div>
);
}
/** A dialog that slides up full-width on phones and sits centred on bigger screens. */
function Sheet({ title, subtitle, onClose, children, footer, onSubmit }: {
title: React.ReactNode;
subtitle?: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
onSubmit?: (e: React.FormEvent) => void;
}) {
const Box = onSubmit ? "form" : "div";
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
<Box onSubmit={onSubmit} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3 p-4 border-b">
<div className="min-w-0">
<h2 className="font-semibold">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
</div>
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto overflow-x-hidden overscroll-contain">{children}</div>
{footer && <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">{footer}</div>}
</Box>
</div>
);
}
function CouponCard({ coupon: c, status, onUses, onEdit, onToggle }: { coupon: Coupon; status: Status; onUses: () => void; onEdit: () => void; onToggle: () => void }) {
const restricted = c.allowedUsers.length
? `${c.allowedUsers.length} ${c.allowedUsers.length === 1 ? "person" : "people"}`
: c.restrictRoles.length ? c.restrictRoles.map(r => (r === "user" ? "members" : r)).join(", ") : c.restrictDomain ? `@${c.restrictDomain}` : null;
const dates = c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end date"}` : null;
const limits = [
c.maxUses ? `${c.maxUses} uses in total` : null,
c.maxUsesPerUser ? `${c.maxUsesPerUser} per person` : null,
c.minQuantity ? `min ${c.minQuantity} tickets` : null,
].filter(Boolean).join(" · ");
const live = status.key === "active" || status.key === "scheduled";
return (
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${live ? "bg-white" : "bg-gray-50"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-mono font-semibold text-gray-900 break-all">{c.code}</div>
{c.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{c.description}</div>}
</div>
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
</div>
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
<span className="text-xl font-semibold text-gray-900">{c.summary}</span>
{c.options.length > 0 && (
<span className="text-xs text-gray-500">on {c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{c.events.length === 0
? <span className="rounded-full bg-gray-100 text-gray-700 px-2.5 py-1 text-xs">All events</span>
: c.events.map(e => (
<span key={e.eventId} className="rounded-full bg-brand-50 text-brand-800 border border-brand-100 px-2.5 py-1 text-xs break-words">{e.event.title}</span>
))}
</div>
<div className="text-xs text-gray-600 space-y-0.5">
<div>Used <strong>{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}×</strong> · {fmtRand(c.totalDiscount)} given</div>
{restricted && <div className="flex items-center gap-1 text-amber-700"><Users className="w-3.5 h-3.5 shrink-0" /> Only for {restricted}</div>}
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5 shrink-0" /> {dates}</div>}
{limits && <div>{limits}</div>}
</div>
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
<button onClick={onUses} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<ListChecks className="w-3.5 h-3.5" /> Uses
</button>
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 sm:ml-auto ${c.active ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
<Power className="w-3.5 h-3.5" /> {c.active ? "Turn off" : "Turn on"}
</button>
</div>
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
@@ -365,8 +422,22 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const [eventQuery, setEventQuery] = useState("");
const [showPast, setShowPast] = useState(false);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Upcoming events soonest first. Past events only when asked for, but ones already
// picked always stay in the list so they can be unticked.
const eventChoices = useMemo(() => {
const q = eventQuery.trim().toLowerCase();
const upcoming = events.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = events.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return [...upcoming, ...past]
.filter(e => showPast || !isPast(e) || d.eventIds.includes(e.id))
.filter(e => !q || e.title.toLowerCase().includes(q));
}, [events, eventQuery, showPast, d.eventIds]);
const pastCount = events.filter(isPast).length;
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
@@ -415,184 +486,200 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
}
};
const label = "block text-sm font-medium text-gray-700 mb-1";
const optional = <span className="font-normal text-gray-400">(optional)</span>;
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
<option value="percent">Percentage</option>
<option value="fixed">Fixed amount (R)</option>
<option value="comp">Free (100%)</option>
</select>
</div>
{d.type !== "comp" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<div className="flex items-end">
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
{events.map(ev => (
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span>{ev.title}</span>
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
</label>
))}
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-4 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<Sheet
title={couponId ? "Edit code" : "New coupon code"}
onClose={onClose}
onSubmit={save}
footer={(
<>
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
</button>
</>
)}
>
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}>Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className={label}>Description {optional}</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
<p className="text-xs text-gray-500 mt-1">Only staff see this.</p>
</div>
</div>
</form>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Discount</label>
<div className="grid grid-cols-3 gap-2">
{([["percent", "Percentage", "e.g. 20% off"], ["fixed", "Amount off", "e.g. R50 off"], ["comp", "Free", "100% off"]] as [CouponType, string, string][]).map(([t, name, hint]) => (
<button type="button" key={t} onClick={() => set("type", t)} aria-pressed={d.type === t}
className={`text-left rounded-lg border p-2.5 transition-colors ${d.type === t ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
<div className="text-sm font-medium text-gray-900">{name}</div>
<div className="text-xs text-gray-500">{hint}</div>
</button>
))}
</div>
{d.type !== "comp" && (
<div className="mt-3 sm:max-w-[12rem]">
<label className={label}>{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" inputMode="decimal" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<label className="mt-3 flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
<div>
<label className={label}>Events</label>
<p className="text-xs text-gray-500 mb-2">
{d.eventIds.length === 0 ? "None picked: the code works for every event." : `${d.eventIds.length} picked. The code only works for ${d.eventIds.length === 1 ? "this event" : "these events"}.`}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Find an event" value={eventQuery} onChange={e => setEventQuery(e.target.value)} />
</div>
{pastCount > 0 && (
<label className="flex items-center gap-2 text-sm text-gray-700 shrink-0">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
)}
</div>
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
{eventChoices.map(ev => (
<label key={ev.id} className={`flex items-center gap-2 px-3 py-2 text-sm ${d.eventIds.includes(ev.id) ? "bg-brand-50/60" : ""}`}>
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span className="min-w-0 flex-1 truncate">{ev.title}</span>
<span className="text-xs text-gray-500 shrink-0">{fmtDate(ev.startDate)}{isPast(ev) ? " · past" : ""}</span>
</label>
))}
{eventChoices.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">{eventQuery ? "No events match." : "No upcoming events."}</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className={label}>Only these tickets / items {optional}</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3 bg-gray-50">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm py-0.5">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6 py-0.5">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}>Valid from {optional}</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className={label}>Valid until {optional}</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className={label}>Max total uses</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Max uses per person</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Minimum tickets</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className={label}>Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-x-4 gap-y-2 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.active} onChange={e => set("active", e.target.checked)} />
<span>Active <span className="block text-xs text-gray-500">Discounts already given keep their amount if you turn it off later.</span></span>
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
</Sheet>
);
}