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:
@@ -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. "STAFF50": 50% off for staff, or "WELCOME20": 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'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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user