"use client"; import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; import { useDismissingState } from "@/hooks/useDismissingState"; import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react"; type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund"; const MODE_LABELS: Record = { registration: "REGISTRATION", payment: "PAYMENT", checkin: "CHECK-IN", tickets: "TICKETS", refund: "REFUND", }; const MODE_ICONS: Record = { registration: UserPlus, payment: CreditCard, checkin: CheckSquare, tickets: Ticket, refund: RotateCcw, }; // ─── Fuzzy search helpers ───────────────────────────────────────────────────── function fuzzyFilterRegs(allRegs: any[], search: string): any[] { if (!search) return allRegs; if (search.length === 1) { const q = search.toLowerCase(); return allRegs.filter(r => String(r.user?.name || "").toLowerCase().includes(q) || String(r.user?.email || "").toLowerCase().includes(q) || String(r.user?.phoneNumber || "").includes(q) ); } return allRegs .map(r => ({ r, score: scoreUser(r.user || {}, search) })) .filter(x => x.score >= 0.45) .sort((a, b) => b.score - a.score) .map(x => x.r); } // ─── Pricing helpers ───────────────────────────────────────────────────────── function effectiveOptionUnit(opt: any): number { const base = opt.price || 0; const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId); if (!tiers.length) return base; const now = new Date(); const hit = tiers .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) .filter((t: any) => now < t.deadline) .sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price); return hit.length ? hit[0].price : base; } function effectiveVariantUnit(opt: any, variant: any): number { const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0; const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []; const vtiers = allTiers.filter((t: any) => t.variantId === variant.id); const tiers = vtiers.length ? vtiers : allTiers.filter((t: any) => !t.variantId); if (!tiers.length) return base; const now = new Date(); const hit = tiers .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) .filter((t: any) => now < t.deadline) .sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price); return hit.length ? hit[0].price : base; } function initQuantities(eventOptions: any[]): Record { const map: Record = {}; (eventOptions || []).forEach((o: any) => { if ((o.variants || []).length > 0) { (o.variants as any[]).forEach((v: any) => { map[`${o.id}::${v.id}`] = 0; }); } else { map[o.id] = 0; } }); return map; } function ticketLabel(t: any): string { const opt = t.registrationOption?.eventOption?.name || "Ticket"; const variant = t.registrationOption?.variant?.name; return variant ? `${opt} — ${variant}` : opt; } export default function AtTheDoorPage() { const { user, loading, token } = useAuth(); const router = useRouter(); const [events, setEvents] = useState([]); const [eventId, setEventId] = useState(""); const [eventOptions, setEventOptions] = useState([]); const [showOptionsModal, setShowOptionsModal] = useState(false); const [pendingUser, setPendingUser] = useState(null); const [pendingEditReg, setPendingEditReg] = useState(null); const [confirming, setConfirming] = useState(false); const [quantities, setQuantities] = useState>({}); // Already-issued ticket quantity per option/variant, keyed like `quantities` — a // registration being edited can never drop an item below this (tickets are never // deleted or shrunk, only ever grown, once paid). const [minQuantities, setMinQuantities] = useState>({}); const [showDonationModal, setShowDonationModal] = useState(false); const [donationUser, setDonationUser] = useState(null); const [showNewAttendeeModal, setShowNewAttendeeModal] = useState(false); const [newAttendeeSeed, setNewAttendeeSeed] = useState(""); useEffect(() => { if (!token) return; (async () => { try { const evs = await apiFetch("/api/events/all", { authToken: token }); const now = Date.now(); const active = (evs || []).filter(ev => { const t = new Date(ev.endDate).getTime(); // Registrations/payments are rejected server-side for closed (cashed-up) // events — don't offer them here. return !isNaN(t) && t > now && ev.cashupStatus !== 'closed'; }); active.sort( (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() ); setEvents(active); if (active.length > 0) { setEventId(active[0].id); // default } } catch {} })(); }, [token]); useEffect(() => { if (!token || !eventId) return; (async () => { try { const ev = await apiFetch(`/api/events/${eventId}`, { authToken: token }); setEventOptions(ev.options || ev.eventOptions || []); } catch { setEventOptions([]); } })(); }, [eventId, token]); const canView = useMemo(() => { const role = user?.role; return role === "admin" || role === "supervisor" || role === "staff"; }, [user]); useEffect(() => { if (loading) return; if (!user) router.replace("/login"); }, [user, loading]); const [mode, setMode] = useState("registration"); const [activeRegistration, setActiveRegistration] = useState(null); const [info, setInfo] = useDismissingState(null); const [error, setError] = useDismissingState(null); const handleRegistrationCreated = (registration: any) => { setActiveRegistration(registration); // 🚀 Jump automatically (see effect below) }; const handleRegistrationSelected = (registration: any) => { setActiveRegistration(registration); }; useEffect(() => { if (!activeRegistration) return; const id = setTimeout(() => { // Fully paid → head straight to check-in; otherwise there's still a // balance to collect, so go capture payment first. setMode(activeRegistration.status === "paid" ? "checkin" : "payment"); }, 0); return () => clearTimeout(id); }, [activeRegistration]); const confirmRegistration = async () => { if (confirming) return; const opts = Object.entries(quantities) .filter(([, qty]) => qty > 0) .map(([key, quantity]) => { const [eventOptionId, variantId] = key.split("::"); return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) }; }); if (!opts.length) { setError("Select at least one item"); return; } for (const [key, minQty] of Object.entries(minQuantities)) { if (minQty > 0 && (quantities[key] || 0) < minQty) { const [eventOptionId, variantId] = key.split("::"); const opt = eventOptions.find((o: any) => o.id === eventOptionId); const variant = variantId ? (opt?.variants || []).find((v: any) => v.id === variantId) : null; const label = variant ? `${opt?.name || "item"} (${variant.name})` : (opt?.name || "item"); setError(`Cannot reduce "${label}" below the ${minQty} already issued`); return; } } setConfirming(true); try { if (pendingEditReg) { // Edit existing registration — replace options via PUT const res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, { method: "PUT", authToken: token, body: { options: opts } }); setShowOptionsModal(false); setPendingEditReg(null); setInfo("Registration updated"); handleRegistrationSelected(res); } else if (pendingUser) { // New attendee const res = await apiFetch("/api/registrations/manual", { method: "POST", authToken: token, body: { eventId, user: { name: pendingUser.name, ...(pendingUser.email ? { email: pendingUser.email } : {}), ...(pendingUser.phone ? { phoneNumber: pendingUser.phone } : {}), }, options: opts, notificationPreference: pendingUser.notifPref, } }); setShowOptionsModal(false); handleRegistrationCreated(res); } } catch (e: any) { setError(e?.message || "Failed"); } finally { setConfirming(false); } }; // Opens the new-attendee modal (pre-filled with whatever the user typed in search) const handleShowNewAttendee = (seed: string) => { setNewAttendeeSeed(seed); setShowNewAttendeeModal(true); }; // Called when NewAttendeeModal is confirmed — proceed to options selection const handleNewAttendeeConfirm = ({ name, email, phone, notifPref }: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => { const qtyMap = initQuantities(eventOptions); // Default main ticket to 1 (first variant if variants exist) eventOptions.forEach(o => { if (!o.isMainTicket) return; if ((o.variants || []).length > 0) { qtyMap[`${o.id}::${(o.variants as any[])[0].id}`] = 1; } else { qtyMap[o.id] = 1; } }); setQuantities(qtyMap); setMinQuantities({}); setPendingUser({ name, email: email || null, phone: phone || null, notifPref }); setPendingEditReg(null); setShowNewAttendeeModal(false); setShowOptionsModal(true); }; // Edit an existing registration — pre-fill with current quantities. // Re-fetches the registration fresh rather than trusting the (possibly up to 5-minutes-stale, // see DoorRegistrationPanel's polling interval) cached search-result snapshot, so the // already-issued-ticket floor below is always computed from current data. const handleEditRegistration = async (reg: any) => { let freshReg = reg; try { freshReg = await apiFetch(`/api/registrations/${reg.id}`, { authToken: token }); } catch (e: any) { setError(e?.message || "Failed to load latest registration data"); return; } const qtyMap = initQuantities(eventOptions); const regOptions = freshReg.registrationOptions || freshReg.options || []; regOptions.forEach((ro: any) => { const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId; if (key in qtyMap) qtyMap[key] = ro.quantity || 0; }); // Already-issued ticket quantity per option/variant — floor for the edit below const minQtyMap: Record = {}; regOptions.forEach((ro: any) => { const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId; const issuedQty = (ro.tickets || []).reduce((s: number, t: any) => s + (t.quantity || 0), 0); if (issuedQty > 0) minQtyMap[key] = (minQtyMap[key] || 0) + issuedQty; }); setQuantities(qtyMap); setMinQuantities(minQtyMap); setPendingEditReg(freshReg); setPendingUser(null); setShowOptionsModal(true); }; const handlePaymentCaptured = async (registration?: any, partial?: boolean) => { if (partial && registration) { setActiveRegistration(registration); setInfo("Partial payment recorded"); return; } if (!registration && !activeRegistration) return; const reg = registration || activeRegistration; // Tickets are generated and emailed/WhatsApped automatically server-side // once a registration reaches "paid" (see paymentController) — no need to // fetch/print them here, just move straight to checking the attendee in. setActiveRegistration(reg); setInfo("Payment recorded — tickets sent. Ready to check in."); setMode("checkin"); }; const handleDonation = (user?: any) => { setDonationUser(user || null); // null = anonymous setShowDonationModal(true); }; return (

At The Door

{!canView && (
Access denied.
)} {error && (
{error}
)} {info && (
{info}
)} {/* Mode Buttons */}
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => { const Icon = MODE_ICONS[m]; return ( ); })}
{/* ✅ Panels */} {mode === "registration" && ( )} {mode === "payment" && ( )} {mode === "checkin" && ( )} {mode === "tickets" && ( )} {mode === "refund" && ( )} { setShowOptionsModal(false); setPendingEditReg(null); }} options={eventOptions} quantities={quantities} setQuantities={setQuantities} minQuantities={minQuantities} onConfirm={confirmRegistration} confirming={confirming} isEdit={!!pendingEditReg} totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)} /> setShowDonationModal(false)} user={donationUser} token={token} eventId={eventId} setError={setError} /> setShowNewAttendeeModal(false)} seed={newAttendeeSeed} onConfirm={handleNewAttendeeConfirm} />
); } function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRegistration, onNewAttendee, onDonation, setError }: any) { const [search, setSearch] = useState(""); const [allRegs, setAllRegs] = useState([]); const [loading, setLoading] = useState(false); // Load registrations for this event — once on mount/eventId change, then every 5 min useEffect(() => { if (!token || !eventId) return; let cancelled = false; const load = async () => { try { setLoading(true); const regs = await apiFetch(`/api/registrations/event/${eventId}`, { authToken: token }); if (!cancelled) setAllRegs(regs || []); } catch { if (!cancelled) setAllRegs([]); } finally { if (!cancelled) setLoading(false); } }; load(); const timer = setInterval(load, 300000); return () => { cancelled = true; clearInterval(timer); }; }, [token, eventId]); const results = fuzzyFilterRegs(allRegs, search); return (
Find / Register
setSearch(e.target.value)} autoFocus />
{results.map(r => (
{r.user?.name || "Guest"}
{r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""} {r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""}
{r.status === "paid" ? "PAID ✅" : `UNPAID · ${r.status}`}
))} {/* New attendee row */}
onNewAttendee(search)} >
{search ? `New attendee "${search}"…` : "New attendee…"}
+ Register
{!loading && allRegs.length === 0 && (
No registrations yet for this event.
)}
); } function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) { const [amount, setAmount] = useState(""); const [method, setMethod] = useState("card"); const [saving, setSaving] = useState(false); const [showRefund, setShowRefund] = useState(false); const [showSendTickets, setShowSendTickets] = useState(false); if (!registration) { return (
No registration selected
); } const options = registration.options || registration.registrationOptions || []; const payments = registration.payments || []; // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans // multiple early-bird prices) — fall back to the old client-side estimate otherwise. const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => { const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.eventOption?.price ?? opt.price ?? 0); const qty = opt.quantity || 0; return sum + price * qty; }, 0); const paidValue = payments.reduce( (sum: number, p: any) => sum + (p.amount || 0), 0 ); const balance = Math.max(0, totalValue - paidValue); const save = async () => { if (!balance) { setError("Nothing due on this registration"); return; } try { setSaving(true); await apiFetch("/api/payments", { method: "POST", authToken: token, body: { registrationId: registration.id, amount: parseFloat(amount), method } }); const updated = await apiFetch(`/api/registrations/${registration.id}`, { authToken: token }); const updatedOptions = updated.options || updated.registrationOptions || []; const updatedPayments = updated.payments || []; const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => { const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.eventOption?.price ?? opt.price ?? 0); return sum + price * (opt.quantity || 0); }, 0); const paidValue = updatedPayments.reduce( (sum: number, p: any) => sum + (p.amount || 0), 0 ); const updatedBalance = Math.max(0, totalValue - paidValue); if (updatedBalance === 0) { onSuccess(updated); // 🎯 ONLY NOW generate tickets } else { onSuccess(updated, true); // 🎯 partial payment flow } } catch (e: any) { setError(e?.message || "Payment failed"); } finally { setSaving(false); } }; return (
Payment
{/* ✅ User */}
{registration.user?.name || "Guest"}
{/* ✅ BIG MONEY BLOCK 😌🔥 */}
TOTAL
R {totalValue.toFixed(2)}
PAID
R {paidValue.toFixed(2)}
DUE
R {balance.toFixed(2)}
{/* ✅ Amount + Full Pay */}
setAmount(e.target.value)} autoFocus /> {balance > 0 && ( )}
{/* ✅ Method */} {/* Capture — only show when there's a balance */} {balance > 0 && ( )} {/* Print / Send — show when fully paid */} {balance === 0 && (
)} {/* Refund */} {paidValue > 0 && ( )} setShowRefund(false)} token={token} registration={registration} maxRefund={paidValue} onRefunded={(updated: any) => { setShowRefund(false); onSuccess(updated, true); }} setError={setError} /> setShowSendTickets(false)} token={token} registration={registration} setError={setError} setInfo={() => {}} />
); } function DoorTicketsPanel({ token, eventId }: any) { const [search, setSearch] = useState(""); const [allTickets, setAllTickets] = useState([]); const [loading, setLoading] = useState(false); const [sendTarget, setSendTarget] = useState(null); // Load all tickets for the event once useEffect(() => { if (!token || !eventId) return; (async () => { try { setLoading(true); const res = await apiFetch(`/api/tickets/event/${eventId}`, { authToken: token }); setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : [])); } catch { setAllTickets([]); } finally { setLoading(false); } })(); }, [token, eventId]); const tickets = !search ? allTickets : (() => { const q = search.toLowerCase(); const byId = allTickets.filter(t => String(t.id).toLowerCase().includes(q) || String(t.qrCode || "").toLowerCase().includes(q) ); if (byId.length > 0) return byId; if (search.length < 2) return allTickets.filter(t => String(t.user?.name || "").toLowerCase().includes(q) || String(t.user?.email || "").toLowerCase().includes(q) ); return allTickets .map(t => ({ t, score: scoreUser(t.user || {}, search) })) .filter(x => x.score >= 0.45) .sort((a, b) => b.score - a.score) .map(x => x.t); })(); const load = async () => { if (!token || !eventId) return; try { setLoading(true); const res = await apiFetch(`/api/tickets/event/${eventId}`, { authToken: token }); setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : [])); } catch { setAllTickets([]); } finally { setLoading(false); } }; const print = (ticket: any) => { const w = window.open("", "_blank"); if (!w) return; const eventTitle = ticket.event?.title || ticket.eventId || "Event"; const eventDate = ticket.event?.startDate ? new Date(ticket.event.startDate) : null; const dateStr = eventDate ? eventDate.toLocaleDateString("en-ZA", { day: "numeric", month: "long", year: "numeric" }) : ""; const type = ticketLabel(ticket); const qty = ticket.quantity || 1; const holder = ticket.user?.name || ticket.userId || ""; const qrData = encodeURIComponent(ticket.qrCode || ticket.id); const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${qrData}`; w.document.write(`Ticket
${eventTitle}
${dateStr ? `
${dateStr}
` : ""}
${type}
${holder ? `
${holder}
` : ""}
Qty: ${qty}
QR
${ticket.id}
`); w.document.close(); w.focus(); }; return (
Tickets
setSearch(e.target.value)} autoFocus />
{loading && (
Searching…
)}
{tickets.map(t => (
{t.user?.name || "Guest"}
{ticketLabel(t)}
))} {!loading && tickets.length === 0 && (
No tickets found
)}
{sendTarget && ( setSendTarget(null)} token={token} registration={{ id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId, user: sendTarget.user, }} setError={() => {}} setInfo={() => {}} /> )}
); } function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) { const [ticketsForEvent, setTicketsForEvent] = useState([]); const [loading, setLoading] = useState(false); const [qtyByTicket, setQtyByTicket] = useState>({}); const [submittingId, setSubmittingId] = useState(null); const load = async () => { if (!token || !eventId) return; try { setLoading(true); const res = await apiFetch(`/api/tickets/event/${eventId}`, { authToken: token }); const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []); setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket)); } catch { setTicketsForEvent([]); } finally { setLoading(false); } }; useEffect(() => { load(); }, [token, eventId]); if (!registration) { return (
No registration selected
); } const myTickets = ticketsForEvent .filter(t => t.registrationOption?.registration?.id === registration.id) .map(t => { const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0); const remaining = (t.quantity || 1) - totalRedeemed; return { ...t, totalRedeemed, remaining }; }); const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1); const setQtyFor = (t: any, n: number) => setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) })); const commit = async (ticket: any) => { const qty = qtyFor(ticket); try { setSubmittingId(ticket.id); const res = await apiFetch( `/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`, { method: "POST", authToken: token, body: { qty } } ); setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${ res?.remaining > 0 ? ` — ${res.remaining} remaining` : " — fully checked in" }. Confirmation sent.`); await load(); } catch (e: any) { setError(e?.message || "Check-in failed"); } finally { setSubmittingId(null); } }; return (
Check-In
{/* ✅ User */}
{registration.user?.name || "Guest"}
{loading && (
Loading…
)} {!loading && myTickets.length === 0 && (
No Main Tickets on this registration
)}
{myTickets.map(t => { const qty = qtyFor(t); return (
{ticketLabel(t)}
TOTAL
{t.quantity || 1}
CHECKED IN
{t.totalRedeemed}
REMAINING
{t.remaining}
{t.remaining > 0 ? (
{qty}
) : (
Fully checked in ✓
)}
); })}
); } function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) { if (!open) return null; const safeOptions: any[] = options || []; const total = safeOptions.reduce((sum: number, o: any) => { if ((o.variants || []).length > 0) { return sum + (o.variants as any[]).reduce((vs: number, v: any) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0); } return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o); }, 0); const belowPaid = isEdit && total < totalPaid; const belowIssued = isEdit && Object.entries(minQuantities).some( ([key, minQty]: [string, any]) => minQty > 0 && (quantities[key] || 0) < minQty ); const stepper = (key: string, delta: number) => setQuantities((q: Record) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) })); return (
{/* Header */}
{isEdit ? "Edit Registration" : "Select Items"}
{isEdit && totalPaid > 0 && (
Already paid: R {Number(totalPaid).toFixed(2)} — new total must be at least this amount.
)}
{/* Scrollable Content */}
{safeOptions.map((opt: any) => { const hasVariants = (opt.variants || []).length > 0; if (hasVariants) { return (
{opt.name} {opt.isMainTicket && • Main}
{(opt.variants as any[]).map((v: any) => { const unit = effectiveVariantUnit(opt, v); const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price; const key = `${opt.id}::${v.id}`; const minQty = minQuantities[key] || 0; return (
{v.name}
R {unit.toFixed(2)} {unit < basePrice && basePrice > 0 && (early bird)}
{minQty > 0 &&
{minQty} already issued
}
{quantities[key] || 0}
); })}
); } const unit = effectiveOptionUnit(opt); const minQty = minQuantities[opt.id] || 0; return (
{opt.name}
R {unit.toFixed(2)} {unit < opt.price && opt.price > 0 && (early bird, was R {opt.price.toFixed(2)})} {opt.isMainTicket ? " • Main" : ""}
{minQty > 0 &&
{minQty} already issued
}
{quantities[opt.id] || 0}
); })}
{/* Footer */}
{belowPaid && (
New total (R {total.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
)} {belowIssued && (
One or more items are below the quantity already issued as tickets. Please increase the selection.
)}
Total: R {total.toFixed(2)}
); } function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: any) { const [amount, setAmount] = useState(""); const [method, setMethod] = useState("cash"); const [saving, setSaving] = useState(false); if (!open) return null; const saveDonation = async () => { const amt = parseFloat(amount); if (!amt || amt <= 0) { setError("Enter valid amount"); return; } try { setSaving(true); await apiFetch("/api/payments", { method: "POST", authToken: token, body: { amount: amt, method, userId: user?.id || undefined, eventId, isDonation: true } }); onSuccess?.(); setAmount(""); onClose(); } catch (e: any) { setError(e?.message || "Donation failed"); } finally { setSaving(false); } }; return (
Donation
{user ? user.name : "Anonymous Donation"}
setAmount(e.target.value)} autoFocus />
); } function SendTicketsModal({ open, onClose, token, registration, setError, setInfo }: any) { const [channel, setChannel] = useState<"email" | "whatsapp" | "both">("email"); const [phone, setPhone] = useState(""); const [email, setEmail] = useState(""); const [sending, setSending] = useState(false); const [localError, setLocalError] = useDismissingState(""); const [localInfo, setLocalInfo] = useState(""); useEffect(() => { if (open && registration) { setPhone(registration.user?.phoneNumber || ""); setEmail(registration.user?.email?.endsWith("@guest.local") ? "" : registration.user?.email || ""); setLocalError(""); setLocalInfo(""); } }, [open, registration]); if (!open) return null; const send = async () => { setLocalError(""); if ((channel === "email" || channel === "both") && !email.trim()) { setLocalError("Email address is required for email delivery."); return; } if ((channel === "whatsapp" || channel === "both") && !phone.trim()) { setLocalError("Phone number is required for WhatsApp delivery."); return; } setSending(true); try { await apiFetch("/api/tickets/send-to", { method: "POST", authToken: token, body: { registrationId: registration.id, channel, overrideEmail: email.trim() || undefined, overridePhone: phone.trim() || undefined, }, }); setLocalInfo("Tickets sent successfully."); setTimeout(() => { setLocalInfo(""); onClose(); }, 7000); } catch (e: any) { setLocalError(e?.message || "Failed to send tickets"); } finally { setSending(false); } }; return (
Send Tickets
{registration?.user?.name || "Guest"}
{(["email", "whatsapp", "both"] as const).map((c) => ( ))}
{(channel === "email" || channel === "both") && (
setEmail(e.target.value)} />
)} {(channel === "whatsapp" || channel === "both") && (
setPhone(e.target.value)} />
)} {localError &&

{localError}

} {localInfo &&

{localInfo}

}
); } function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded, setError }: any) { const [amount, setAmount] = useState(""); const [method, setMethod] = useState("cash"); const [reason, setReason] = useState(""); const [saving, setSaving] = useState(false); const [localError, setLocalError] = useDismissingState(""); useEffect(() => { if (open) { setAmount(""); setReason(""); setLocalError(""); } }, [open]); if (!open) return null; const save = async (e: React.FormEvent) => { e.preventDefault(); const amt = parseFloat(amount); if (!amt || amt <= 0) { setLocalError("Enter a valid amount."); return; } if (amt > maxRefund) { setLocalError(`Cannot exceed total paid (R ${Number(maxRefund).toFixed(2)}).`); return; } setSaving(true); setLocalError(""); try { await apiFetch("/api/payments/refund", { method: "POST", authToken: token, body: { userId: registration.userId, registrationId: registration.id, amount: amt, method, reason: reason || undefined, } }); // Re-fetch registration to get updated status & payments const updated = await apiFetch(`/api/registrations/${registration.id}`, { authToken: token }); onRefunded(updated); } catch (e: any) { setLocalError(e?.message || "Refund failed."); } finally { setSaving(false); } }; return (
Issue Refund
{registration.user?.name || "Guest"} · Total paid: R {Number(maxRefund).toFixed(2)}
setAmount(e.target.value)} autoFocus />
setReason(e.target.value)} />
{localError &&

{localError}

}
); } function DoorRefundPanel({ token, eventId, setError, setInfo }: any) { const [search, setSearch] = useState(""); const [allRegs, setAllRegs] = useState([]); const [loading, setLoading] = useState(false); const [selectedReg, setSelectedReg] = useState(null); const [showRefund, setShowRefund] = useState(false); const load = async () => { if (!token || !eventId) return; try { setLoading(true); const regs = await apiFetch(`/api/registrations/event/${eventId}`, { authToken: token }); setAllRegs(regs || []); } catch { setAllRegs([]); } finally { setLoading(false); } }; useEffect(() => { load(); }, [token, eventId]); // Only show registrations that have at least one payment const paidRegs = allRegs.filter(r => (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) > 0 ); const results = fuzzyFilterRegs(paidRegs, search); const handleRefunded = async (updated: any) => { setShowRefund(false); setSelectedReg(updated); setInfo("Refund recorded"); // Refresh list const regs = await apiFetch(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null); if (regs) setAllRegs(regs); }; const maxRefund = selectedReg ? (selectedReg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) : 0; return (
Refunds
{!selectedReg ? ( <> setSearch(e.target.value)} autoFocus />
{results.map(r => { const paidValue = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0); return (
{r.user?.name || "Guest"}
{r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""} {r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""}
Paid: R {paidValue.toFixed(2)} · {r.status}
); })} {!loading && results.length === 0 && (
No paid registrations found for this event.
)}
) : (
{selectedReg.user?.name || "Guest"}
{selectedReg.user?.email && !selectedReg.user.email.endsWith("@guest.local") ? selectedReg.user.email : ""} {selectedReg.user?.phoneNumber ? ` · ${selectedReg.user.phoneNumber}` : ""}
TOTAL
R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => { const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined ? Number(o.priceSnapshot) : (o.eventOption?.price || o.price || 0); return s + price * (o.quantity || 0); }, 0)).toFixed(2)}
PAID
R {maxRefund.toFixed(2)}
{(selectedReg.payments || []).length > 0 && (
Payment history
{selectedReg.payments.map((p: any, i: number) => (
{p.amount < 0 ? "Refund" : "Payment"} — {p.method || "—"} {p.reason ? ` (${p.reason})` : ""} R {Number(p.amount).toFixed(2)}
))}
)}
{maxRefund > 0 ? ( ) : (
Nothing to refund — net paid is R 0.00.
)}
)} setShowRefund(false)} token={token} registration={selectedReg} maxRefund={maxRefund} onRefunded={handleRefunded} setError={setError} />
); } function NewAttendeeModal({ open, onClose, seed, onConfirm }: { open: boolean; onClose: () => void; seed: string; onConfirm: (data: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => void; }) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [phone, setPhone] = useState(""); const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email"); const [err, setErr] = useState(""); // Pre-fill name from whatever was typed in search useEffect(() => { if (open) { setName(seed || ""); setEmail(""); setPhone(""); setNotifPref("email"); setErr(""); } }, [open, seed]); if (!open) return null; const derivedPref = email.trim() && phone.trim() ? notifPref : phone.trim() ? "whatsapp" : "email"; const submit = (e: React.FormEvent) => { e.preventDefault(); if (!name.trim()) { setErr("Name is required."); return; } if (!email.trim() && !phone.trim()) { setErr("Provide at least an email or phone number."); return; } onConfirm({ name: name.trim(), email: email.trim(), phone: phone.trim(), notifPref: derivedPref }); }; return (
New Attendee
setName(e.target.value)} placeholder="Full name" autoFocus />
setEmail(e.target.value)} placeholder="Email address" />
{ const v = e.target.value; setPhone(v); if (v.trim() && !email.trim()) setNotifPref("whatsapp"); else if (!v.trim() && email.trim()) setNotifPref("email"); }} placeholder="+27…" />
{/* Preference selector — shown only when both channels are provided */} {email.trim() && phone.trim() && (
{(["email", "whatsapp", "both"] as const).map((p) => ( ))}
)}

At least one of email or cell number is required. If no email is provided, a guest account is created.

{err &&

{err}

}
); }