Add coupon codes and staff pricing

Percentage, fixed-amount and comp codes, limited by event, ticket option
or variant, dates, use counts, minimum quantity, and people, roles or an
email domain. The discount is locked in on the registration (mirrored on
Registration.discountTotal so every total sees it), shows on invoices,
receipts, emails and the Yoco checkout, and is released on cancellation.
Use limits are enforced under an advisory lock.

Ticket options and variants can also carry a staff price that Staff and
above get automatically. Adds a Supervisor > Coupon codes page, code
fields on registration, My Events, manual registration, at the door and
the kiosk, a Discounts report, and audit log entries. Also fixes Yoco
line items and the free-registration check for lines spanning several
price tranches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 11:43:10 +02:00
co-authored by Claude Opus 5.5
parent 7ce125e36c
commit 709ea38c10
40 changed files with 2846 additions and 203 deletions
@@ -18,6 +18,10 @@ const ACTIONS = [
"event_updated",
"event_deleted",
"settings_updated",
"coupon_created",
"coupon_updated",
"coupon_disabled",
"coupon_applied_manual",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -30,6 +34,10 @@ const ACTION_LABELS: Record<string, string> = {
event_updated: "Event updated",
event_deleted: "Event deactivated",
settings_updated: "Settings updated",
coupon_created: "Coupon created",
coupon_updated: "Coupon edited",
coupon_disabled: "Coupon turned off",
coupon_applied_manual: "Coupon applied/removed by staff",
};
interface AuditLogEntry {
+2 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
@@ -100,6 +101,7 @@ export default function AtTheDoorPage() {
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<any | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
const [confirming, setConfirming] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
@@ -226,14 +228,28 @@ export default function AtTheDoorPage() {
try {
if (pendingEditReg) {
// Edit existing registration — replace options via PUT
const res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
let res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
method: "PUT",
authToken: token,
body: { options: opts }
});
let message = "Registration updated";
if (doorCoupon) {
// Applied after the items are saved, so it's worked out on the final items.
try {
res = await apiFetch(`/api/registrations/${pendingEditReg.id}/coupon`, {
method: "POST",
authToken: token,
body: { code: doorCoupon.code },
});
message = `Registration updated — code ${doorCoupon.code} applied`;
} catch (ce: any) {
message = `Registration updated, but the code wasn't applied: ${ce?.message || "unknown error"}`;
}
}
setShowOptionsModal(false);
setPendingEditReg(null);
setInfo("Registration updated");
setInfo(message);
handleRegistrationSelected(res);
} else if (pendingUser) {
// New attendee
@@ -249,9 +265,11 @@ export default function AtTheDoorPage() {
},
options: opts,
notificationPreference: pendingUser.notifPref,
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
}
});
setShowOptionsModal(false);
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
handleRegistrationCreated(res);
}
} catch (e: any) {
@@ -283,6 +301,7 @@ export default function AtTheDoorPage() {
setMinQuantities({});
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
setDoorCoupon(null);
setShowNewAttendeeModal(false);
setShowOptionsModal(true);
};
@@ -319,6 +338,7 @@ export default function AtTheDoorPage() {
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
setDoorCoupon(null);
setShowOptionsModal(true);
};
@@ -463,6 +483,12 @@ export default function AtTheDoorPage() {
confirming={confirming}
isEdit={!!pendingEditReg}
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
eventId={eventId}
token={token}
coupon={doorCoupon}
setCoupon={setDoorCoupon}
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
/>
<DonationModal
open={showDonationModal}
@@ -1147,9 +1173,16 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode }: any) {
if (!open) return null;
const basket = Object.entries(quantities as Record<string, number>)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
});
const safeOptions: any[] = options || [];
const total = safeOptions.reduce((sum: number, o: any) => {
@@ -1254,8 +1287,21 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
One or more items are below the quantity already issued as tickets. Please increase the selection.
</div>
)}
{existingCode ? (
<div className="mb-3 text-sm text-green-700">Code <span className="font-mono font-semibold">{existingCode}</span> is applied to this registration.</div>
) : (
<div className="mb-3">
<CouponCodeField eventId={eventId} basket={basket} token={token} forUserId={couponForUserId} applied={coupon} onChange={setCoupon} />
</div>
)}
<div className="flex justify-between items-center">
<div className="text-lg font-semibold">Total: R {total.toFixed(2)}</div>
<div>
{coupon && coupon.discount > 0 && !isEdit && (
<div className="text-xs text-gray-500"><span className="line-through">R {coupon.subtotal.toFixed(2)}</span> <span className="text-green-700">−R {coupon.discount.toFixed(2)}</span></div>
)}
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : total).toFixed(2)}</div>
{coupon && isEdit && <div className="text-xs text-green-700">Code {coupon.code} is applied when you save</div>}
</div>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 border rounded" disabled={confirming}>
Cancel
@@ -0,0 +1,596 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
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";
type CouponType = "percent" | "fixed" | "comp";
type Role = "user" | "staff" | "supervisor" | "admin";
type EventLite = {
id: string;
title: string;
startDate: string;
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
};
type Coupon = {
id: string;
code: string;
description: string | null;
type: CouponType;
value: number;
stackEarlyBird: boolean;
validFrom: string | null;
validUntil: string | null;
maxUses: number | null;
maxUsesPerUser: number | null;
minQuantity: number | null;
restrictRoles: Role[];
restrictDomain: string | null;
active: boolean;
summary: string;
uses: number;
totalDiscount: number;
events: { eventId: string; event: { id: string; title: string } }[];
options: { eventOptionId: string; variantId: string | null; eventOption: { name: string; eventId: string }; variant: { name: string } | null }[];
allowedUsers: { userId: string; user: { id: string; name: string; email: string } }[];
};
type Redemption = {
id: string;
discountAmount: number;
createdAt: string;
releasedAt: string | null;
user: { name: string; email: string };
appliedBy: { name: string } | null;
registration: { status: string; event: { title: string } };
};
type Draft = {
code: string;
description: string;
type: CouponType;
value: string;
stackEarlyBird: boolean;
validFrom: string;
validUntil: string;
maxUses: string;
maxUsesPerUser: string;
minQuantity: string;
eventIds: string[];
options: { eventOptionId: string; variantId: string | null }[];
restrictTo: "anyone" | "roles" | "users" | "domain";
restrictRoles: Role[];
restrictDomain: string;
users: { id: string; name: string; email: string }[];
active: boolean;
};
const blankDraft = (): Draft => ({
code: "", description: "", type: "percent", value: "", stackEarlyBird: false,
validFrom: "", validUntil: "", maxUses: "", maxUsesPerUser: "1", minQuantity: "",
eventIds: [], options: [], restrictTo: "anyone", restrictRoles: ["staff", "supervisor", "admin"],
restrictDomain: "", users: [], active: true,
});
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
const toLocalInput = (iso: string | null) => {
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
function draftFromCoupon(c: Coupon): Draft {
const restrictTo: Draft["restrictTo"] = c.allowedUsers.length ? "users" : c.restrictRoles.length ? "roles" : c.restrictDomain ? "domain" : "anyone";
return {
code: c.code, description: c.description || "", type: c.type, value: c.type === "comp" ? "" : String(c.value),
stackEarlyBird: c.stackEarlyBird, validFrom: toLocalInput(c.validFrom), validUntil: toLocalInput(c.validUntil),
maxUses: c.maxUses ? String(c.maxUses) : "", maxUsesPerUser: c.maxUsesPerUser ? String(c.maxUsesPerUser) : "",
minQuantity: c.minQuantity ? String(c.minQuantity) : "", eventIds: c.events.map(e => e.eventId),
options: c.options.map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId })),
restrictTo, restrictRoles: c.restrictRoles.length ? c.restrictRoles : ["staff", "supervisor", "admin"],
restrictDomain: c.restrictDomain || "", users: c.allowedUsers.map(u => u.user), active: c.active,
};
}
function bodyFromDraft(d: Draft) {
return {
code: d.code.trim(),
description: d.description.trim() || null,
type: d.type,
value: d.type === "comp" ? 0 : Number(d.value),
stackEarlyBird: d.stackEarlyBird,
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
maxUses: d.maxUses || null,
maxUsesPerUser: d.maxUsesPerUser || null,
minQuantity: d.minQuantity || null,
eventIds: d.eventIds,
options: d.options,
restrictRoles: d.restrictTo === "roles" ? d.restrictRoles : [],
restrictDomain: d.restrictTo === "domain" ? d.restrictDomain.trim() : null,
userIds: d.restrictTo === "users" ? d.users.map(u => u.id) : [],
active: d.active,
};
}
const fmtDate = (iso: string | null) => (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";
function couponStatus(c: Coupon): { label: string; cls: string } {
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" };
}
export default function CouponsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [coupons, setCoupons] = useState<Coupon[]>([]);
const [events, setEvents] = useState<EventLite[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [showOff, setShowOff] = useState(false);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setFetching(true);
try {
const [c, ev] = await Promise.all([
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token }),
]);
setCoupons(c || []);
setEvents(ev || []);
} catch (e: any) {
setError(e?.message || "Failed to load coupons");
} finally {
setFetching(false);
}
}, [token, setError]);
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
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 toggleActive = async (c: Coupon) => {
try {
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
load();
} catch (e: any) {
setError(e?.message || "Failed to update coupon");
}
};
const openUses = async (c: Coupon) => {
try {
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
setViewing({ coupon: c, redemptions: full.redemptions || [] });
} catch (e: any) {
setError(e?.message || "Failed to load uses");
}
};
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="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" />
</div>
<div>
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Coupon codes</h1>
<p className="text-sm text-gray-500">Discounts for staff, leaders, volunteers and promotions</p>
</div>
</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"
>
<Plus className="w-4 h-4" /> New code
</button>
)}
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage coupon codes.</div>
)}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
{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">
<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)} />
</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
</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>
</div>
)}
{editing && (
<CouponModal
token={token!}
events={events}
couponId={editing.id}
initial={editing.draft}
onClose={() => setEditing(null)}
onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }}
/>
)}
{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>
)}
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
couponId: string | null;
initial: Draft;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [d, setD] = useState<Draft>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
useEffect(() => {
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
setUserResults(res?.data || []);
} catch { setUserResults([]); }
}, 300);
return () => clearTimeout(t);
}, [userQuery, d.restrictTo, token]);
const toggleEvent = (id: string) => {
const eventIds = d.eventIds.includes(id) ? d.eventIds.filter(x => x !== id) : [...d.eventIds, id];
// Drop scoped options from events that are no longer selected.
const keepOptionIds = new Set(events.filter(e => eventIds.includes(e.id)).flatMap(e => e.eventOptions.map(o => o.id)));
setD(prev => ({ ...prev, eventIds, options: prev.options.filter(o => keepOptionIds.has(o.eventOptionId)) }));
};
const scopeKey = (o: { eventOptionId: string; variantId: string | null }) => `${o.eventOptionId}::${o.variantId || ""}`;
const toggleScope = (eventOptionId: string, variantId: string | null) => {
const key = scopeKey({ eventOptionId, variantId });
set("options", d.options.some(o => scopeKey(o) === key) ? d.options.filter(o => scopeKey(o) !== key) : [...d.options, { eventOptionId, variantId }]);
};
const save = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (d.restrictTo === "users" && d.users.length === 0) { setError("Add at least one person, or choose another restriction."); return; }
setSaving(true);
try {
const body = bodyFromDraft(d);
if (couponId) {
await apiFetch(`/api/coupons/${couponId}`, { method: "PUT", authToken: token, body });
onSaved(`${body.code.toUpperCase()} saved.`);
} else {
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
onSaved(`${body.code.toUpperCase()} created.`);
}
} catch (err: any) {
setError(err?.message || "Failed to save");
} finally {
setSaving(false);
}
};
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">
<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>
</form>
</div>
);
}
@@ -50,6 +50,7 @@ type Variant = {
name: string;
price: string; // empty → use option base price
stockLimit: string;
staffPrice?: string; // empty → use the option's staff price
earlyBirdTiers: Tier[];
};
@@ -59,6 +60,7 @@ type OptionDraft = {
price: string;
isMainTicket: boolean;
stockLimit: string;
staffPrice?: string; // empty → no staff price
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
};
@@ -193,6 +195,11 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24"
value={opt.stockLimit} onChange={e => upd(i, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5" title="Staff and above pay this automatically when it's lower than the price they'd otherwise pay">Staff price (R)</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
</div>
<div className="flex items-center gap-3 pb-1">
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
@@ -253,6 +260,11 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20"
value={v.stockLimit} onChange={e => updVariant(vi, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Staff price</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20" placeholder="(option)"
value={v.staffPrice ?? ""} onChange={e => updVariant(vi, { staffPrice: e.target.value })} />
</div>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}>✕</button>
</div>
))}
@@ -815,6 +827,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
price: String(o.price ?? ""),
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t: any) => !t.variantId)
@@ -823,6 +836,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
id: v.id, name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
// Variant-level tiers — filter by variantId
earlyBirdTiers: allTiers
.filter((t: any) => t.variantId === v.id)
@@ -908,13 +922,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
id: v.id, name: v.name,
price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
}));
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), variants: variantsPayload }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
@@ -946,7 +961,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// New option added during edit
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
if (created?.id) {
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
@@ -984,6 +999,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const variantsPayload = opt.variants.map((v, vi) => ({
name: v.name, price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
}));
let optId: string;
@@ -992,13 +1008,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
optId = existingMain.id;
} else {
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
optId = created?.id;
}
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { UserPlus, CreditCard } from "lucide-react";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -215,7 +216,12 @@ export default function ManualRegistrationPage() {
.map(x => x.u);
}, [userQuery, allUsers]);
// Discount code, checked against the picked account (or a walk-in when none is picked).
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const selectUser = (u: any) => {
setPickedUserId(u.id || null);
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
setDropdownOpen(false);
@@ -308,9 +314,12 @@ export default function ManualRegistrationPage() {
options: opts,
user: guest,
notificationPreference: resolvedPref,
...(coupon ? { couponCode: coupon.code } : {}),
}
});
setMessage("Manual registration created successfully.");
setMessage(res?.couponError
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
@@ -325,6 +334,8 @@ export default function ManualRegistrationPage() {
setNotifPref("email");
setUserQuery("");
setDropdownOpen(false);
setCoupon(null);
setPickedUserId(null);
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
@@ -504,7 +515,7 @@ export default function ManualRegistrationPage() {
{guest.name && (
<button
type="button"
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); }}
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); setPickedUserId(null); }}
className="text-xs text-gray-400 hover:text-gray-600 text-left"
>
✕ Clear
@@ -581,8 +592,27 @@ export default function ManualRegistrationPage() {
)}
</div>
{selectedEventId && totalDue > 0 && (
<div className="mt-6 max-w-md">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={token}
forUserId={pickedUserId}
applied={coupon}
onChange={setCoupon}
/>
</div>
)}
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<div className="text-sm">
Total due: <span className="font-semibold">R {(coupon ? coupon.total : totalDue).toFixed(2)}</span>
{coupon && coupon.discount > 0 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
</div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</>
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
+60
View File
@@ -497,6 +497,41 @@ export default function UserDashboardPage() {
return false;
}, [activeReg, activeEventHasForm]);
// Discount code on an existing registration
const [codeInput, setCodeInput] = useState("");
const [codeBusy, setCodeBusy] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
const applyCodeToActive = async () => {
if (!token || !activeRegId || !codeInput.trim()) return;
setCodeBusy(true);
setCodeError(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(activeRegId)}/coupon`, {
method: 'POST',
authToken: token,
body: { code: codeInput.trim() },
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
setCodeInput("");
setInfo(res?.message || 'Code applied.');
} catch (e: any) {
setCodeError(e?.message || "Couldn't apply that code");
} finally {
setCodeBusy(false);
}
};
// Cancel registration state
const [cancelLoading, setCancelLoading] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
@@ -1034,10 +1069,35 @@ export default function UserDashboardPage() {
</ul>
</div>
);
const activeCoupon = (activeReg.couponRedemptions || [])[0];
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
{Number(activeReg.discountTotal || 0) > 0 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal))}
</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
<div className="mt-3">
<div className="flex gap-2">
<input
value={codeInput}
onChange={e => { setCodeInput(e.target.value.toUpperCase()); setCodeError(null); }}
placeholder="Have a discount code?"
className="flex-1 min-w-0 border rounded px-2 py-1 text-sm font-mono uppercase"
aria-label="Discount code"
/>
<button
onClick={applyCodeToActive}
disabled={codeBusy || !codeInput.trim()}
className="px-3 py-1 text-sm rounded border border-brand-600 text-brand-700 hover:bg-brand-50 disabled:opacity-50"
>{codeBusy ? 'Applying…' : 'Apply'}</button>
</div>
{codeError && <p className="text-xs text-red-600 mt-1">{codeError}</p>}
</div>
)}
</div>
);
})() : (
@@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
@@ -246,6 +247,7 @@ export default function RegisterForm({ event }: { event: Event }) {
const [registrationId, setRegistrationId] = useState<string | null>(null);
const [creatingCheckout, setCreatingCheckout] = useState(false);
const [creatingRegistration, setCreatingRegistration] = useState(false);
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
// Guest details (for unauthenticated free-event registration)
const [guestName, setGuestName] = useState("");
@@ -292,7 +294,11 @@ export default function RegisterForm({ event }: { event: Event }) {
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
}, [event]);
const buildItems = () => {
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
// The server prices the basket (staff price, tiers) when checking a code, so its total wins.
const payable = coupon ? coupon.total : total;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
if (qty <= 0) return;
@@ -304,7 +310,7 @@ export default function RegisterForm({ event }: { event: Event }) {
}
});
return items;
};
}
const submitRegistration = async () => {
const items = buildItems();
@@ -322,18 +328,25 @@ export default function RegisterForm({ event }: { event: Event }) {
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
const res = await apiFetch<{ id: string; totalDueComputed?: number; couponError?: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items },
body: { eventId, options: items, ...(coupon ? { couponCode: coupon.code } : {}) },
authToken: token,
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
if (hasForm) {
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
} else {
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((total || 0).toFixed(2))}`;
const due = res.totalDueComputed ?? payable;
const next = hasForm
? `/forms?registrationId=${encodeURIComponent(res.id)}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`;
if (res.couponError) {
// The code ran out between checking and registering — the registration is
// saved at full price, so say so before moving on.
setError(`You're registered, but the code couldn't be applied: ${res.couponError} Continuing at full price…`);
setTimeout(() => { window.location.href = next; }, 4000);
return;
}
window.location.href = next;
}
} catch (e: any) {
setError(e?.message || "Registration failed");
@@ -475,8 +488,22 @@ export default function RegisterForm({ event }: { event: Event }) {
);
})()}
{isLoggedIn && total > 0 && !eventSoldOut && (
<div className="mt-6">
<CouponCodeField eventId={eventId} basket={basket} token={token} applied={coupon} onChange={setCoupon} />
</div>
)}
<div className="mt-6 flex items-center justify-between">
<div className="text-lg font-semibold">Total: R{total.toFixed(2)}</div>
<div>
{coupon && coupon.discount > 0 && (
<div className="text-sm text-gray-500">
<span className="line-through">R{coupon.subtotal.toFixed(2)}</span>
<span className="ml-2 text-green-700">−R{coupon.discount.toFixed(2)}</span>
</div>
)}
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
</div>
{!registrationId ? (
eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
+24 -2
View File
@@ -3,6 +3,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -134,6 +135,7 @@ export default function SelfServicePage() {
const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
// Set while a supervisor with two-factor on still has to enter their code.
const [setupChallenge, setSetupChallenge] = useState<TwoFactorChallenge | null>(null);
const [setupCode, setSetupCode] = useState("");
@@ -408,6 +410,7 @@ export default function SelfServicePage() {
// activation email/WhatsApp at them afterward.
skipActivationNotice: !createAccount,
notificationPreference: notificationPref,
...(kioskCoupon ? { couponCode: kioskCoupon.code } : {}),
};
if (createAccount && visitorPassword) {
payload.user.password = visitorPassword;
@@ -421,7 +424,7 @@ export default function SelfServicePage() {
setCurrentRegistrationId(result?.id || null);
setCurrentUserId(result?.userId || null);
setRegistrationIsFree(result?.status === "paid" || total === 0);
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
setConfirmationName(visitorName.trim());
// If event has a required form and there are main tickets, show form step
@@ -522,6 +525,7 @@ export default function SelfServicePage() {
}
function resetForm() {
setKioskCoupon(null);
setVisitorName("");
setVisitorEmail("");
setVisitorPhone("");
@@ -989,9 +993,27 @@ export default function SelfServicePage() {
);
})}
</div>
{total > 0 && selectedEventId && (
<div className="mt-4">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={supervisorToken}
forUserId={matchedAccount?.id ?? null}
applied={kioskCoupon}
onChange={setKioskCoupon}
/>
</div>
)}
{total > 0 && (
<div className="mt-3 text-right text-base font-semibold text-gray-800">
Total: {fmtCurrency(total)}
{kioskCoupon && kioskCoupon.discount > 0 && (
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon.subtotal)}</span>
)}
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : total)}
</div>
)}
</div>
@@ -13,6 +13,7 @@ import {
FileBarChart,
BarChart3,
ClipboardList,
TicketPercent,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -33,6 +34,7 @@ export const REPORTS = [
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
] as const satisfies ReadonlyArray<{
key: string;
label: string;
+111 -3
View File
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp } from "lucide-react";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
@@ -173,6 +173,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
const [auditRows, setAuditRows] = useState<any[]>([]);
const [discountRows, setDiscountRows] = useState<any[]>([]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
@@ -282,6 +283,19 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
return byEv;
};
// Coupon uses (active redemptions on non-cancelled registrations) for the Discounts report.
const loadDiscounts = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [] as any[];
const query = new URLSearchParams({ eventIds: eventIds.join(",") });
if (dateFrom) query.set("from", dateFrom);
if (dateTo) query.set("to", dateTo);
try {
return await apiFetch<any[]>(`/api/coupons/redemptions?${query.toString()}`, { authToken: token });
} catch (e) {
return [];
}
};
const loadCashupAudit = async (eventIds: string[]) => {
if (!token) return [] as any[];
const query = new URLSearchParams();
@@ -298,6 +312,29 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
}
};
// Discounts report: one row per code per event.
const discountSummary = useMemo(() => {
const byKey = new Map<string, { code: string; summary: string; event: string; uses: number; total: number }>();
for (const r of discountRows) {
const event = r.registration?.event?.title || "—";
const key = `${r.coupon?.code}__${event}`;
const row = byKey.get(key) || { code: r.coupon?.code || "?", summary: r.summary || "", event, uses: 0, total: 0 };
row.uses += 1;
row.total += Number(r.discountAmount || 0);
byKey.set(key, row);
}
const rows = Array.from(byKey.values()).sort((a, b) => b.total - a.total);
const byCode = new Map<string, number>();
rows.forEach(r => byCode.set(r.code, (byCode.get(r.code) || 0) + r.total));
return {
rows,
totalUses: rows.reduce((s, r) => s + r.uses, 0),
totalValue: rows.reduce((s, r) => s + r.total, 0),
codes: byCode.size,
chart: Array.from(byCode.entries()).map(([label, value]) => ({ label, value })),
};
}, [discountRows]);
// View handler according to selected report
const onView = async () => {
setBusy(true);
@@ -351,6 +388,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
} else if (report === "cashupAudit") {
const rows = await loadCashupAudit(selectedEventIds);
setAuditRows(rows);
} else if (report === "discounts") {
setDiscountRows(await loadDiscounts(selectedEventIds));
}
setReady(true);
} finally {
@@ -924,6 +963,25 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
};
}
if (report === "discounts") {
return {
title: 'Discounts (coupon codes)', subtitle, kind: 'table', orientation: 'portrait',
stats: [
{ label: 'Value given away', value: `R ${discountSummary.totalValue.toFixed(2)}`, tone: 'rose' },
{ label: 'Uses', value: String(discountSummary.totalUses), tone: 'violet' },
{ label: 'Codes used', value: String(discountSummary.codes), tone: 'blue' },
],
chart: { title: 'Value given away, by code', data: discountSummary.chart.map(c => ({ ...c, displayValue: `R ${c.value.toFixed(2)}` })) },
note: 'Counts codes on registrations that are not cancelled. Cancelling a registration frees its code.',
table: {
columns: ["Code", "Discount", "Event", "Uses", "Value (R)"],
rows: [
...discountSummary.rows.map(r => [r.code, r.summary, r.event, r.uses, Number(r.total.toFixed(2))]),
["Total", "", "", discountSummary.totalUses, Number(discountSummary.totalValue.toFixed(2))],
],
},
};
}
if (report === "cashup") {
const rows: (string | number)[][] = [];
const incomeByMethod: Record<string, number> = {};
@@ -958,7 +1016,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return;
const title = ev?.title || evId;
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0);
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0);
paidByAll += METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0);
METHOD_KEYS.forEach(m => { paidByMethod[m] = (paidByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); });
donationsProfit += f.unallocatedDonationsTotal || 0;
@@ -972,6 +1030,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// as profit" — no need to repeat it here.
rows.push([title, "Sales", "What was sold", ""]);
(f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))]));
if (f.discountsTotal > 0) rows.push([title, "", "Less discount codes", -Number(f.discountsTotal.toFixed(2))]);
rows.push([title, "Paid by", "", ""]);
METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))]));
rows.push([title, "Cashup comparison", "", ""]);
@@ -1108,6 +1167,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
financeReport: "Finance Report",
profitReport: "Profit Report",
cashupAudit: "Cashup Audit Trail",
discounts: "Discounts Report",
};
const doPrintPdf = async () => {
@@ -1558,6 +1618,54 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
)}
</div>
)}
{report === "discounts" && (
<div>
{discountSummary.rows.length === 0 ? (
<div className="text-sm text-gray-500">No discount codes used for the selected events and dates.</div>
) : (
<>
<StatTileRow>
<StatTile icon={TicketPercent} label="Value given away" value={`R ${discountSummary.totalValue.toFixed(2)}`} tone="rose" />
<StatTile icon={ShoppingCart} label="Uses" value={String(discountSummary.totalUses)} tone="violet" />
<StatTile icon={Gift} label="Codes used" value={String(discountSummary.codes)} tone="blue" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Value given away, by code</div>
<HorizontalBarChart data={discountSummary.chart} valueFormatter={v => `R ${v.toFixed(2)}`} />
</div>
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[640px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Code</th>
<th className="py-2 px-3">Discount</th>
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3 text-right">Uses</th>
<th className="py-2 px-3 text-right">Value (R)</th>
</tr>
</thead>
<tbody>
{discountSummary.rows.map(row => (
<tr key={`${row.code}-${row.event}`} className="border-t border-gray-100">
<td className="py-2 px-3 font-mono">{row.code}</td>
<td className="py-2 px-3">{row.summary}</td>
<td className="py-2 px-3">{row.event}</td>
<td className="py-2 px-3 text-right">{row.uses}</td>
<td className="py-2 px-3 text-right">R {row.total.toFixed(2)}</td>
</tr>
))}
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
<td className="py-2 px-3" colSpan={3}>Total</td>
<td className="py-2 px-3 text-right">{discountSummary.totalUses}</td>
<td className="py-2 px-3 text-right">R {discountSummary.totalValue.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
</>
)}
</div>
)}
{report === "donations" && (
<div>
{Object.keys(paymentsByEvent).length === 0 ? (
@@ -1878,7 +1986,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<StatTileRow>
<StatTile icon={ShoppingCart} label="What was sold" value={`R ${(f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0).toFixed(2)}`} tone="green" />
<StatTile icon={ShoppingCart} label={f.discountsTotal > 0 ? "What was sold (after discounts)" : "What was sold"} value={`R ${((f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0)).toFixed(2)}`} tone="green" />
<StatTile icon={CreditCard} label="Paid by (all methods)" value={`R ${METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0).toFixed(2)}`} tone="blue" />
<StatTile icon={HandHeart} label="Donations counted as profit" value={`R ${(f.unallocatedDonationsTotal || 0).toFixed(2)}`} tone="rose" />
<StatTile icon={FileSpreadsheet} label="Total costs" value={`R ${(f.totalCosts || 0).toFixed(2)}`} tone="amber" />
@@ -0,0 +1,140 @@
"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 && <label className="block text-sm font-medium text-gray-700 mb-1">Discount code</label>}
<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>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export const dashboardUserHelpContent: HelpContent = {
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick.
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird or discount-code saving, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick. If you have a discount code and haven&apos;t paid in full yet, enter it in the registration popup.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only — tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
+1 -1
View File
@@ -20,7 +20,7 @@ export const eventDetailHelpContent: HelpContent = {
<p>The tickets panel lists every option available for this event, with live availability.</p>
<div className="space-y-4">
<GuideItem icon={Tag} title="Early-bird pricing" tone="amber">
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends.
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends. Got a discount code? Enter it on the registration page under &quot;Have a code?&quot; (you need to be logged in).
</GuideItem>
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
&quot;N remaining&quot; appears once stock runs low, and &quot;Sold out&quot; once an option is fully booked.
+6
View File
@@ -21,6 +21,7 @@ import { supervisorManualHelpContent, supervisorManualRegistrationLegacyHelpCont
import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
import { supervisorFormsHelpContent } from "./supervisor-forms";
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
@@ -147,6 +148,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/payments",
content: supervisorPaymentsHelpContent,
},
{
slug: "supervisor-coupons",
matches: pathname => pathname === "/dashboard/supervisor/coupons",
content: supervisorCouponsHelpContent,
},
{
slug: "supervisor-forms",
matches: pathname => pathname === "/dashboard/supervisor/forms" || pathname === "/dashboard/admin/forms",
@@ -42,6 +42,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
<div className="space-y-4">
<p>Selecting a registration automatically jumps to Payment mode if it&apos;s unpaid, or Check-in mode if it&apos;s already fully paid.</p>
<p>When editing an existing registration&apos;s items, quantities can&apos;t be reduced below what&apos;s already been printed or paid for.</p>
<p>The item picker has a &quot;Have a code?&quot; field for discount codes. On an existing registration the code is applied when you save.</p>
</div>
),
},
@@ -0,0 +1,73 @@
import React from "react";
import { TicketPercent, Plus, Users, CalendarClock, Layers, Ban, BarChart2, BadgePercent, DoorOpen } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/coupons. */
export const supervisorCouponsHelpContent: HelpContent = {
title: "Coupon codes",
subtitle: "Discounts for staff, leaders, volunteers and promotions.",
tabs: [
{
key: "create",
label: "Creating codes",
icon: Plus,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={TicketPercent} title="Discount type" tone="brand">
A percentage off, a fixed rand amount off, or free (100%). People type the code on the registration page; it isn&apos;t case-sensitive.
</GuideItem>
<GuideItem icon={Layers} title="Events and tickets" tone="green">
Leave the events unticked for every event, or pick one or more. Once events are picked you can limit the code to certain ticket options or variants — otherwise it discounts the whole registration.
</GuideItem>
<GuideItem icon={BadgePercent} title="Early-bird prices" tone="violet">
By default the better of the two prices wins: the code is worked out against the standard price, and only what beats the early-bird price comes off. Tick &quot;Also apply on top of early-bird prices&quot; to stack them.
</GuideItem>
<GuideItem icon={CalendarClock} title="Dates and limits" tone="amber">
Optional start and end dates, a maximum number of uses overall, a maximum per person (1 by default), and a minimum number of tickets.
</GuideItem>
<GuideItem icon={Users} title="Who can use it" tone="blue">
Anyone with the code, people with certain roles (e.g. Staff and above), specific people, or an email domain. Restricted codes need the person to be logged in, so they can&apos;t be passed around a group chat.
</GuideItem>
</div>
</div>
),
},
{
key: "using",
label: "How discounts work",
icon: TicketPercent,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={TicketPercent} title="Locked in when applied" tone="brand">
The discount is saved on the registration and shows as its own line on invoices, receipts, emails and the payment page. Turning a code off later doesn&apos;t change discounts already given.
</GuideItem>
<GuideItem icon={Layers} title="Changing items" tone="green">
If the items on a registration change, percentage and free codes are worked out again on the new items. Fixed-amount codes keep their amount.
</GuideItem>
<GuideItem icon={Ban} title="Cancellations" tone="rose">
Cancelling a registration frees its code so it can be used again. A free code that covers everything marks the registration paid and sends the tickets.
</GuideItem>
<GuideItem icon={DoorOpen} title="Applying for someone" tone="amber">
Manual registration, At the door and the kiosk all have a &quot;Have a code?&quot; field. Every code staff apply is recorded in the admin audit log.
</GuideItem>
</div>
<p className="text-xs text-gray-500">Staff price: a ticket option can also have a staff price (set in the event&apos;s Items &amp; pricing step) that Staff and above get automatically, with no code.</p>
</div>
),
},
{
key: "tracking",
label: "Tracking",
icon: BarChart2,
content: (
<div className="space-y-4">
<p>&quot;Uses&quot; on each code lists who used it, on which event, and how much it took off. The <strong>Discounts</strong> report (Reports → Financial) totals uses and value given away per code and event, and the Finance report shows sales after discounts.</p>
<p className="text-xs text-gray-500">A code that has been used can&apos;t be renamed — turn it off and create a new one instead.</p>
</div>
),
},
],
};
@@ -35,7 +35,7 @@ export const supervisorEventsHelpContent: HelpContent = {
Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias.
</GuideItem>
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
Ticket options and stock, variants of each option, and early-bird pricing tiers.
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it&apos;s lower than the normal price.
</GuideItem>
<GuideItem icon={Layers} title="Sections" tone="violet">
Group ticket options under named headings as shown on the public registration page.
@@ -14,7 +14,7 @@ export const supervisorManualHelpContent: HelpContent = {
icon: UserPlus,
content: (
<div className="space-y-4">
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities — early-bird pricing is applied automatically — then create the registration. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities — early-bird and staff pricing are applied automatically, and a discount code can be added under &quot;Have a code?&quot; — then create the registration. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it — switch tabs to take payment right away.</p>
</div>
),