- Add matching lucide-react icons to tab/mode switcher buttons on payments, at-the-door, manual, email-attendees, whatsapp-attendees, and admin cashup pages, mirroring icons already used in their help menus - Fix navbar Logout button sitting lower than other nav links (missing border/padding classes that other links use for their active-underline) - Fix stat card labels getting truncated on mobile by removing the ellipsis-cut label and widening the mobile grid to one column - Fix Revenue trend / Top performing events rendering outside the viewport on mobile by containing horizontal overflow and truncating long event titles in the table
1918 lines
82 KiB
TypeScript
1918 lines
82 KiB
TypeScript
"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<Mode, string> = {
|
||
registration: "REGISTRATION",
|
||
payment: "PAYMENT",
|
||
checkin: "CHECK-IN",
|
||
tickets: "TICKETS",
|
||
refund: "REFUND",
|
||
};
|
||
|
||
const MODE_ICONS: Record<Mode, LucideIcon> = {
|
||
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<string, number> {
|
||
const map: Record<string, number> = {};
|
||
(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<any[]>([]);
|
||
const [eventId, setEventId] = useState("");
|
||
|
||
const [eventOptions, setEventOptions] = useState<any[]>([]);
|
||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||
const [pendingUser, setPendingUser] = useState<any | null>(null);
|
||
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||
// 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<Record<string, number>>({});
|
||
|
||
const [showDonationModal, setShowDonationModal] = useState(false);
|
||
const [donationUser, setDonationUser] = useState<any | null>(null);
|
||
|
||
const [showNewAttendeeModal, setShowNewAttendeeModal] = useState(false);
|
||
const [newAttendeeSeed, setNewAttendeeSeed] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (!token) return;
|
||
(async () => {
|
||
try {
|
||
const evs = await apiFetch<any[]>("/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<Mode>("registration");
|
||
|
||
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
|
||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||
const [error, setError] = useDismissingState<string | null>(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,
|
||
guestOnly: pendingUser.guestOnly,
|
||
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({ guestOnly: true, 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<any>(`/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<string, number> = {};
|
||
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 (
|
||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||
<div className="flex items-center gap-3 shrink-0">
|
||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||
<DoorOpen className="w-5 h-5 text-brand-600" />
|
||
</div>
|
||
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
|
||
</div>
|
||
<select
|
||
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
|
||
value={eventId}
|
||
onChange={e => setEventId(e.target.value)}
|
||
>
|
||
<option value="">Select event…</option>
|
||
{events.map(ev => (
|
||
<option key={ev.id} value={ev.id}>
|
||
{ev.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 shrink-0 self-start sm:self-auto"
|
||
onClick={() => router.push("/dashboard")}
|
||
>
|
||
Back
|
||
</button>
|
||
</div>
|
||
|
||
{!canView && (
|
||
<div className="p-3 border rounded bg-yellow-50 text-sm mb-4">
|
||
Access denied.
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="p-3 mb-3 border rounded bg-red-100 text-red-700 text-sm animate-pulse">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{info && (
|
||
<div className="p-3 mb-3 border rounded bg-emerald-100 text-emerald-700 text-sm">
|
||
{info}
|
||
</div>
|
||
)}
|
||
|
||
{/* Mode Buttons */}
|
||
<div className="flex gap-2 mb-4 flex-wrap">
|
||
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => {
|
||
const Icon = MODE_ICONS[m];
|
||
return (
|
||
<button
|
||
key={m}
|
||
onClick={() => setMode(m)}
|
||
className={`inline-flex items-center gap-1.5 px-4 py-2 rounded text-sm font-medium border ${
|
||
mode === m
|
||
? m === "refund"
|
||
? "bg-red-600 text-white border-red-600"
|
||
: "bg-brand-600 text-white border-brand-600"
|
||
: "bg-white hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{MODE_LABELS[m]}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* ✅ Panels */}
|
||
{mode === "registration" && (
|
||
<DoorRegistrationPanel
|
||
token={token}
|
||
eventId={eventId}
|
||
onCreated={handleRegistrationCreated}
|
||
onSelected={handleRegistrationSelected}
|
||
onEditRegistration={handleEditRegistration}
|
||
onNewAttendee={handleShowNewAttendee}
|
||
onDonation={handleDonation}
|
||
setError={setError}
|
||
/>
|
||
)}
|
||
|
||
{mode === "payment" && (
|
||
<DoorPaymentPanel
|
||
token={token}
|
||
registration={activeRegistration}
|
||
onSuccess={handlePaymentCaptured}
|
||
setError={setError}
|
||
/>
|
||
)}
|
||
|
||
{mode === "checkin" && (
|
||
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
|
||
)}
|
||
|
||
{mode === "tickets" && (
|
||
<DoorTicketsPanel token={token} eventId={eventId} />
|
||
)}
|
||
|
||
{mode === "refund" && (
|
||
<DoorRefundPanel token={token} eventId={eventId} setError={setError} setInfo={setInfo} />
|
||
)}
|
||
<OptionsModal
|
||
open={showOptionsModal}
|
||
onClose={() => { 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)}
|
||
/>
|
||
<DonationModal
|
||
open={showDonationModal}
|
||
onClose={() => setShowDonationModal(false)}
|
||
user={donationUser}
|
||
token={token}
|
||
eventId={eventId}
|
||
setError={setError}
|
||
/>
|
||
<NewAttendeeModal
|
||
open={showNewAttendeeModal}
|
||
onClose={() => setShowNewAttendeeModal(false)}
|
||
seed={newAttendeeSeed}
|
||
onConfirm={handleNewAttendeeConfirm}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRegistration, onNewAttendee, onDonation, setError }: any) {
|
||
const [search, setSearch] = useState("");
|
||
const [allRegs, setAllRegs] = useState<any[]>([]);
|
||
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<any[]>(`/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 (
|
||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="text-lg font-semibold">Find / Register</div>
|
||
<button
|
||
onClick={async () => {
|
||
if (!token || !eventId) return;
|
||
try {
|
||
setLoading(true);
|
||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||
setAllRegs(regs || []);
|
||
} catch {} finally { setLoading(false); }
|
||
}}
|
||
className="text-xs px-2 py-1 rounded border hover:bg-gray-50"
|
||
>
|
||
{loading ? "Loading…" : "Refresh"}
|
||
</button>
|
||
</div>
|
||
|
||
<input
|
||
className="w-full border rounded px-3 py-3 text-lg"
|
||
placeholder="Name, email or phone…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
|
||
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
|
||
|
||
{results.map(r => (
|
||
<div key={r.id} className="border rounded p-3">
|
||
<div className="flex justify-between items-center">
|
||
<div>
|
||
<div className="font-medium text-sm">{r.user?.name || "Guest"}</div>
|
||
<div className="text-xs text-gray-500">
|
||
{r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""}
|
||
{r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""}
|
||
</div>
|
||
<div className="text-xs text-gray-400">
|
||
{r.status === "paid" ? "PAID ✅" : `UNPAID · ${r.status}`}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white">Open</button>
|
||
<button onClick={() => onEditRegistration(r)} className="px-2 py-1 text-xs rounded bg-emerald-600 text-white">Edit</button>
|
||
<button onClick={() => onDonation(r.user)} className="px-2 py-1 text-xs rounded bg-amber-500 text-white">Donation</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* New attendee row */}
|
||
<div
|
||
className="border rounded p-3 flex justify-between items-center bg-emerald-50 hover:bg-emerald-100 cursor-pointer"
|
||
onClick={() => onNewAttendee(search)}
|
||
>
|
||
<div className="font-medium text-sm text-emerald-800">
|
||
{search ? `New attendee "${search}"…` : "New attendee…"}
|
||
</div>
|
||
<div className="text-xs text-emerald-600 font-medium">+ Register</div>
|
||
</div>
|
||
|
||
{!loading && allRegs.length === 0 && (
|
||
<div className="text-sm text-gray-500">No registrations yet for this event.</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => onDonation()}
|
||
className="w-full py-3 rounded bg-amber-500 text-white font-semibold mt-3"
|
||
>
|
||
Anonymous Donation
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
|
||
No registration selected
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const options = registration.options || registration.registrationOptions || [];
|
||
const payments = registration.payments || [];
|
||
|
||
const totalValue = options.reduce((sum: number, opt: any) => {
|
||
// Use priceSnapshot (authoritative backend price, variant-aware) if available
|
||
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 = 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 (
|
||
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||
|
||
<div className="text-xl font-semibold mb-4">Payment</div>
|
||
|
||
{/* ✅ User */}
|
||
<div className="mb-4">
|
||
<div className="text-lg font-semibold">
|
||
{registration.user?.name || "Guest"}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ✅ BIG MONEY BLOCK 😌🔥 */}
|
||
<div className="grid grid-cols-3 gap-3 mb-5">
|
||
|
||
<div className="border rounded-lg p-3 text-center">
|
||
<div className="text-xs text-gray-500">TOTAL</div>
|
||
<div className="text-lg font-semibold">
|
||
R {totalValue.toFixed(2)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border rounded-lg p-3 text-center">
|
||
<div className="text-xs text-gray-500">PAID</div>
|
||
<div className="text-lg font-semibold">
|
||
R {paidValue.toFixed(2)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border rounded-lg p-3 text-center bg-emerald-50">
|
||
<div className="text-xs text-gray-500">DUE</div>
|
||
<div className="text-2xl font-bold text-emerald-600">
|
||
R {balance.toFixed(2)}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* ✅ Amount + Full Pay */}
|
||
<div className="flex gap-2">
|
||
|
||
<input
|
||
className="flex-1 border rounded px-3 py-4 text-2xl font-semibold"
|
||
placeholder="Amount"
|
||
type="number"
|
||
value={amount}
|
||
onChange={e => setAmount(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
|
||
{balance > 0 && (
|
||
<button
|
||
onClick={() => setAmount(String(balance))}
|
||
className="px-4 rounded bg-brand-600 text-white text-sm font-medium"
|
||
>
|
||
Full
|
||
</button>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* ✅ Method */}
|
||
<select
|
||
className="w-full border rounded px-3 py-3 mt-3 text-lg"
|
||
value={method}
|
||
onChange={e => setMethod(e.target.value)}
|
||
>
|
||
<option value="card">Card</option>
|
||
<option value="cash">Cash</option>
|
||
<option value="eft">EFT</option>
|
||
</select>
|
||
|
||
{/* Capture — only show when there's a balance */}
|
||
{balance > 0 && (
|
||
<button
|
||
onClick={save}
|
||
disabled={saving || !amount}
|
||
className="mt-4 w-full py-4 rounded bg-emerald-600 text-white text-lg font-semibold disabled:opacity-60"
|
||
>
|
||
{saving ? "Saving…" : "Capture Payment"}
|
||
</button>
|
||
)}
|
||
|
||
{/* Print / Send — show when fully paid */}
|
||
{balance === 0 && (
|
||
<div className="mt-4 flex gap-2">
|
||
<button
|
||
onClick={() => onSuccess(registration)}
|
||
className="flex-1 py-3 rounded bg-brand-600 text-white text-sm font-medium"
|
||
>
|
||
Print Tickets
|
||
</button>
|
||
<button
|
||
onClick={() => setShowSendTickets(true)}
|
||
className="flex-1 py-3 rounded bg-green-600 text-white text-sm font-medium"
|
||
>
|
||
Send Tickets
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Refund */}
|
||
{paidValue > 0 && (
|
||
<button
|
||
onClick={() => setShowRefund(true)}
|
||
className="mt-2 w-full py-2 rounded border border-red-300 text-red-600 text-sm hover:bg-red-50"
|
||
>
|
||
Issue Refund
|
||
</button>
|
||
)}
|
||
|
||
<RefundModal
|
||
open={showRefund}
|
||
onClose={() => setShowRefund(false)}
|
||
token={token}
|
||
registration={registration}
|
||
maxRefund={paidValue}
|
||
onRefunded={(updated: any) => {
|
||
setShowRefund(false);
|
||
onSuccess(updated, true);
|
||
}}
|
||
setError={setError}
|
||
/>
|
||
|
||
<SendTicketsModal
|
||
open={showSendTickets}
|
||
onClose={() => setShowSendTickets(false)}
|
||
token={token}
|
||
registration={registration}
|
||
setError={setError}
|
||
setInfo={() => {}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DoorTicketsPanel({ token, eventId }: any) {
|
||
const [search, setSearch] = useState("");
|
||
const [allTickets, setAllTickets] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [sendTarget, setSendTarget] = useState<any | null>(null);
|
||
|
||
// Load all tickets for the event once
|
||
useEffect(() => {
|
||
if (!token || !eventId) return;
|
||
(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await apiFetch<any>(`/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<any>(`/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(`<!doctype html><html><head><title>Ticket</title>
|
||
<style>
|
||
@page { size: A6; margin: 6mm; }
|
||
* { box-sizing: border-box; }
|
||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
|
||
.ticket { border: 1.5px solid #222; border-radius: 3mm; padding: 5mm; height: calc(148mm - 12mm); display: flex; flex-direction: column; justify-content: space-between; }
|
||
.evt { font-weight: bold; font-size: 15pt; line-height: 1.2; }
|
||
.date { font-size: 10pt; color: #555; margin-top: 2mm; }
|
||
.type { font-size: 12pt; margin-top: 2mm; color: #222; }
|
||
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
|
||
.qty { font-size: 10pt; margin-top: 1mm; }
|
||
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1; }
|
||
.qrwrap img { width: 55mm; height: 55mm; display: block; }
|
||
.meta { font-size: 9pt; text-align: center; color: #666; word-break: break-all; }
|
||
</style>
|
||
</head><body>
|
||
<div class="ticket">
|
||
<div>
|
||
<div class="evt">${eventTitle}</div>
|
||
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
|
||
<div class="type">${type}</div>
|
||
${holder ? `<div class="holder">${holder}</div>` : ""}
|
||
<div class="qty">Qty: <strong>${qty}</strong></div>
|
||
</div>
|
||
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
||
<div class="meta">${ticket.id}</div>
|
||
</div>
|
||
<script>
|
||
(function(){
|
||
function go(){
|
||
var imgs=Array.prototype.slice.call(document.images);
|
||
if(!imgs.length){window.print();return;}
|
||
var n=imgs.length;
|
||
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
|
||
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
|
||
}
|
||
if(document.readyState==='complete')go();else window.addEventListener('load',go);
|
||
})();
|
||
</script>
|
||
</body></html>`);
|
||
w.document.close();
|
||
w.focus();
|
||
};
|
||
|
||
return (
|
||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="text-lg font-semibold">Tickets</div>
|
||
<button onClick={load} className="text-xs px-2 py-1 rounded border hover:bg-gray-50">
|
||
{loading ? "Loading…" : "Refresh"}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<input
|
||
className="flex-1 border rounded px-3 py-3 text-lg"
|
||
placeholder="Filter by name, phone or ticket ID…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
|
||
{loading && (
|
||
<div className="text-sm text-gray-500 mt-2">
|
||
Searching…
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
|
||
{tickets.map(t => (
|
||
<div
|
||
key={t.id}
|
||
className="border rounded p-2 flex justify-between items-center"
|
||
>
|
||
<div className="text-sm">
|
||
<div className="font-medium">
|
||
{t.user?.name || "Guest"}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
{ticketLabel(t)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => print(t)}
|
||
className="px-3 py-1 text-xs rounded bg-emerald-600 text-white"
|
||
>
|
||
Print
|
||
</button>
|
||
<button
|
||
onClick={() => setSendTarget(t)}
|
||
className="px-3 py-1 text-xs rounded bg-green-600 text-white"
|
||
>
|
||
Send
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{!loading && tickets.length === 0 && (
|
||
<div className="text-sm text-gray-500">
|
||
No tickets found
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{sendTarget && (
|
||
<SendTicketsModal
|
||
open={true}
|
||
onClose={() => setSendTarget(null)}
|
||
token={token}
|
||
registration={{
|
||
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId,
|
||
user: sendTarget.user,
|
||
}}
|
||
setError={() => {}}
|
||
setInfo={() => {}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
|
||
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
|
||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||
|
||
const load = async () => {
|
||
if (!token || !eventId) return;
|
||
try {
|
||
setLoading(true);
|
||
const res = await apiFetch<any>(`/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 (
|
||
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
|
||
No registration selected
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<any>(
|
||
`/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 (
|
||
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||
|
||
<div className="text-xl font-semibold mb-4">Check-In</div>
|
||
|
||
{/* ✅ User */}
|
||
<div className="mb-4">
|
||
<div className="text-lg font-semibold">
|
||
{registration.user?.name || "Guest"}
|
||
</div>
|
||
</div>
|
||
|
||
{loading && (
|
||
<div className="text-sm text-gray-500">Loading…</div>
|
||
)}
|
||
|
||
{!loading && myTickets.length === 0 && (
|
||
<div className="text-sm text-gray-500">
|
||
No Main Tickets on this registration
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
{myTickets.map(t => {
|
||
const qty = qtyFor(t);
|
||
return (
|
||
<div key={t.id} className="border rounded-lg p-4">
|
||
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||
<div className="border rounded-lg p-3 text-center">
|
||
<div className="text-xs text-gray-500">TOTAL</div>
|
||
<div className="text-lg font-semibold">{t.quantity || 1}</div>
|
||
</div>
|
||
|
||
<div className="border rounded-lg p-3 text-center">
|
||
<div className="text-xs text-gray-500">CHECKED IN</div>
|
||
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
|
||
</div>
|
||
|
||
<div className="border rounded-lg p-3 text-center bg-emerald-50">
|
||
<div className="text-xs text-gray-500">REMAINING</div>
|
||
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{t.remaining > 0 ? (
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => setQtyFor(t, qty - 1)}
|
||
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
|
||
>
|
||
–
|
||
</button>
|
||
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
|
||
<button
|
||
onClick={() => setQtyFor(t, qty + 1)}
|
||
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
|
||
>
|
||
+
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => commit(t)}
|
||
disabled={submittingId === t.id}
|
||
className="ml-auto px-4 py-2 text-sm rounded bg-brand-600 text-white disabled:opacity-50"
|
||
>
|
||
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-emerald-600 font-medium">Fully checked in ✓</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<string, number>) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-xl flex flex-col max-h-[90vh] overflow-hidden">
|
||
|
||
{/* Header */}
|
||
<div className="p-5 border-b flex-shrink-0">
|
||
<div className="text-xl font-semibold">
|
||
{isEdit ? "Edit Registration" : "Select Items"}
|
||
</div>
|
||
{isEdit && totalPaid > 0 && (
|
||
<div className="text-sm text-gray-500 mt-1">
|
||
Already paid: <strong>R {Number(totalPaid).toFixed(2)}</strong> — new total must be at least this amount.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Scrollable Content */}
|
||
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
|
||
{safeOptions.map((opt: any) => {
|
||
const hasVariants = (opt.variants || []).length > 0;
|
||
if (hasVariants) {
|
||
return (
|
||
<div key={opt.id} className="border rounded-xl overflow-hidden">
|
||
<div className="px-4 py-2 bg-gray-50 border-b font-medium text-sm">
|
||
{opt.name}
|
||
{opt.isMainTicket && <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span>}
|
||
</div>
|
||
{(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 (
|
||
<div key={v.id} className="flex items-center justify-between px-4 py-2.5 border-b last:border-b-0">
|
||
<div>
|
||
<div className="text-sm">{v.name}</div>
|
||
<div className="text-xs text-gray-500">
|
||
R {unit.toFixed(2)}
|
||
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird)</span>}
|
||
</div>
|
||
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={() => stepper(key, -1)} disabled={(quantities[key] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
|
||
<div className="w-6 text-center text-base font-semibold">{quantities[key] || 0}</div>
|
||
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
const unit = effectiveOptionUnit(opt);
|
||
const minQty = minQuantities[opt.id] || 0;
|
||
return (
|
||
<div key={opt.id} className="border rounded-xl p-3">
|
||
<div className="font-medium">{opt.name}</div>
|
||
<div className="text-xs text-gray-500">
|
||
R {unit.toFixed(2)}
|
||
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was R {opt.price.toFixed(2)})</span>}
|
||
{opt.isMainTicket ? " • Main" : ""}
|
||
</div>
|
||
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
|
||
<div className="flex gap-3 items-center mt-2">
|
||
<button onClick={() => stepper(opt.id, -1)} disabled={(quantities[opt.id] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
|
||
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
|
||
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 border-t flex-shrink-0 bg-white">
|
||
{belowPaid && (
|
||
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||
New total (R {total.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
|
||
</div>
|
||
)}
|
||
{belowIssued && (
|
||
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||
One or more items are below the quantity already issued as tickets. Please increase the selection.
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between items-center">
|
||
<div className="text-lg font-semibold">Total: R {total.toFixed(2)}</div>
|
||
<div className="flex gap-2">
|
||
<button onClick={onClose} className="px-4 py-2 border rounded" disabled={confirming}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={onConfirm}
|
||
disabled={confirming || belowPaid || belowIssued}
|
||
className="px-4 py-2 rounded bg-emerald-600 text-white disabled:opacity-60 min-w-[140px]"
|
||
>
|
||
{confirming
|
||
? "Processing…"
|
||
: isEdit ? "Save Changes" : "Confirm Registration"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
|
||
|
||
<div className="text-xl font-semibold mb-2">
|
||
Donation
|
||
</div>
|
||
|
||
<div className="text-sm text-gray-500 mb-4">
|
||
{user ? user.name : "Anonymous Donation"}
|
||
</div>
|
||
|
||
<input
|
||
className="w-full border rounded px-3 py-3 text-2xl font-semibold"
|
||
placeholder="Amount"
|
||
type="number"
|
||
value={amount}
|
||
onChange={e => setAmount(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
|
||
<select
|
||
className="w-full border rounded px-3 py-3 mt-3"
|
||
value={method}
|
||
onChange={e => setMethod(e.target.value)}
|
||
>
|
||
<option value="cash">Cash</option>
|
||
<option value="card">Card</option>
|
||
<option value="eft">EFT</option>
|
||
</select>
|
||
|
||
<div className="flex gap-2 mt-4">
|
||
<button
|
||
onClick={onClose}
|
||
className="flex-1 py-3 border rounded"
|
||
>
|
||
Cancel
|
||
</button>
|
||
|
||
<button
|
||
onClick={saveDonation}
|
||
disabled={saving}
|
||
className="flex-1 py-3 rounded bg-emerald-600 text-white font-semibold"
|
||
>
|
||
{saving ? "Saving…" : "Capture"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl w-full max-w-md shadow-xl">
|
||
<div className="p-6 space-y-4">
|
||
<div className="text-xl font-semibold">Send Tickets</div>
|
||
<div className="text-sm text-gray-600">
|
||
<span className="font-medium">{registration?.user?.name || "Guest"}</span>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Send via</label>
|
||
<div className="flex gap-2">
|
||
{(["email", "whatsapp", "both"] as const).map((c) => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
onClick={() => setChannel(c)}
|
||
className={`flex-1 py-2 rounded border text-sm font-medium ${
|
||
channel === c
|
||
? c === "whatsapp" ? "bg-green-600 text-white border-green-600"
|
||
: c === "both" ? "bg-brand-600 text-white border-brand-600"
|
||
: "bg-blue-600 text-white border-blue-600"
|
||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
{c === "both" ? "Both" : c === "whatsapp" ? "WhatsApp" : "Email"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{(channel === "email" || channel === "both") && (
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Email address</label>
|
||
<input
|
||
className="w-full border rounded px-3 py-2 text-sm"
|
||
type="email"
|
||
placeholder="recipient@example.com"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{(channel === "whatsapp" || channel === "both") && (
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">WhatsApp number</label>
|
||
<input
|
||
className="w-full border rounded px-3 py-2 text-sm"
|
||
type="tel"
|
||
placeholder="e.g. 0821234567"
|
||
value={phone}
|
||
onChange={(e) => setPhone(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{localError && <p className="text-sm text-red-600">{localError}</p>}
|
||
{localInfo && <p className="text-sm text-emerald-600">{localInfo}</p>}
|
||
|
||
<div className="flex gap-2 pt-1">
|
||
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded text-sm" disabled={sending}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={send}
|
||
disabled={sending}
|
||
className="flex-1 py-2 rounded bg-brand-600 text-white text-sm font-medium disabled:opacity-60"
|
||
>
|
||
{sending ? "Sending…" : "Send Tickets"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<any>(`/api/registrations/${registration.id}`, { authToken: token });
|
||
onRefunded(updated);
|
||
} catch (e: any) {
|
||
setLocalError(e?.message || "Refund failed.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl w-full max-w-md shadow-xl">
|
||
<form onSubmit={save} className="p-6 space-y-4">
|
||
<div className="text-xl font-semibold text-red-700">Issue Refund</div>
|
||
<div className="text-sm text-gray-600">
|
||
<span className="font-medium">{registration.user?.name || "Guest"}</span>
|
||
<span className="ml-2 text-gray-400">· Total paid: R {Number(maxRefund).toFixed(2)}</span>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Refund amount</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
className="flex-1 border rounded px-3 py-3 text-2xl font-semibold"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
max={maxRefund}
|
||
placeholder="0.00"
|
||
value={amount}
|
||
onChange={e => setAmount(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setAmount(String(maxRefund))}
|
||
className="px-3 rounded border text-sm"
|
||
>
|
||
Full
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Method</label>
|
||
<select
|
||
className="w-full border rounded px-3 py-2"
|
||
value={method}
|
||
onChange={e => setMethod(e.target.value)}
|
||
>
|
||
<option value="cash">Cash</option>
|
||
<option value="card">Card</option>
|
||
<option value="eft">EFT</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Reason <span className="text-gray-400 font-normal">(optional)</span></label>
|
||
<input
|
||
className="w-full border rounded px-3 py-2 text-sm"
|
||
placeholder="e.g. Cancelled, overpayment…"
|
||
value={reason}
|
||
onChange={e => setReason(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{localError && <p className="text-sm text-red-600">{localError}</p>}
|
||
|
||
<div className="flex gap-2 pt-1">
|
||
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded text-sm" disabled={saving}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={saving || !amount}
|
||
className="flex-1 py-2 rounded bg-red-600 text-white text-sm font-medium disabled:opacity-60"
|
||
>
|
||
{saving ? "Processing…" : "Confirm Refund"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||
const [search, setSearch] = useState("");
|
||
const [allRegs, setAllRegs] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [selectedReg, setSelectedReg] = useState<any | null>(null);
|
||
const [showRefund, setShowRefund] = useState(false);
|
||
|
||
const load = async () => {
|
||
if (!token || !eventId) return;
|
||
try {
|
||
setLoading(true);
|
||
const regs = await apiFetch<any[]>(`/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<any[]>(`/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 (
|
||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="text-lg font-semibold text-red-700">Refunds</div>
|
||
<button
|
||
onClick={load}
|
||
className="text-xs px-2 py-1 rounded border hover:bg-gray-50"
|
||
>
|
||
{loading ? "Loading…" : "Refresh"}
|
||
</button>
|
||
</div>
|
||
|
||
{!selectedReg ? (
|
||
<>
|
||
<input
|
||
className="w-full border rounded px-3 py-3 text-lg mb-3"
|
||
placeholder="Search name, email or phone…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
|
||
<div className="space-y-2 max-h-[32rem] overflow-auto">
|
||
{results.map(r => {
|
||
const paidValue = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||
return (
|
||
<div key={r.id} className="border rounded p-3 flex justify-between items-center">
|
||
<div>
|
||
<div className="font-medium text-sm">{r.user?.name || "Guest"}</div>
|
||
<div className="text-xs text-gray-500">
|
||
{r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""}
|
||
{r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""}
|
||
</div>
|
||
<div className="text-xs text-gray-400">
|
||
Paid: R {paidValue.toFixed(2)} · {r.status}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedReg(r)}
|
||
className="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||
>
|
||
Refund
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{!loading && results.length === 0 && (
|
||
<div className="text-sm text-gray-500 py-4 text-center">
|
||
No paid registrations found for this event.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<button
|
||
onClick={() => setSelectedReg(null)}
|
||
className="text-sm text-gray-500 hover:text-gray-700 mb-4 flex items-center gap-1"
|
||
>
|
||
← Back to list
|
||
</button>
|
||
|
||
<div className="border rounded-lg p-4 mb-4">
|
||
<div className="font-semibold text-lg">{selectedReg.user?.name || "Guest"}</div>
|
||
<div className="text-sm text-gray-500">
|
||
{selectedReg.user?.email && !selectedReg.user.email.endsWith("@guest.local") ? selectedReg.user.email : ""}
|
||
{selectedReg.user?.phoneNumber ? ` · ${selectedReg.user.phoneNumber}` : ""}
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||
<div className="border rounded p-3 text-center">
|
||
<div className="text-xs text-gray-500">TOTAL</div>
|
||
<div className="font-semibold">
|
||
R {(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)}
|
||
</div>
|
||
</div>
|
||
<div className="border rounded p-3 text-center bg-red-50">
|
||
<div className="text-xs text-gray-500">PAID</div>
|
||
<div className="font-semibold text-red-700">R {maxRefund.toFixed(2)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{(selectedReg.payments || []).length > 0 && (
|
||
<div className="mt-3">
|
||
<div className="text-xs font-medium text-gray-500 mb-1">Payment history</div>
|
||
<div className="space-y-1">
|
||
{selectedReg.payments.map((p: any, i: number) => (
|
||
<div key={i} className="flex justify-between text-sm">
|
||
<span className={p.amount < 0 ? "text-red-600" : ""}>
|
||
{p.amount < 0 ? "Refund" : "Payment"} — {p.method || "—"}
|
||
{p.reason ? ` (${p.reason})` : ""}
|
||
</span>
|
||
<span className={p.amount < 0 ? "text-red-600 font-medium" : ""}>
|
||
R {Number(p.amount).toFixed(2)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{maxRefund > 0 ? (
|
||
<button
|
||
onClick={() => setShowRefund(true)}
|
||
className="w-full py-3 rounded bg-red-600 text-white font-semibold hover:bg-red-700"
|
||
>
|
||
Issue Refund
|
||
</button>
|
||
) : (
|
||
<div className="text-center text-sm text-gray-500 py-2">
|
||
Nothing to refund — net paid is R 0.00.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<RefundModal
|
||
open={showRefund}
|
||
onClose={() => setShowRefund(false)}
|
||
token={token}
|
||
registration={selectedReg}
|
||
maxRefund={maxRefund}
|
||
onRefunded={handleRefunded}
|
||
setError={setError}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl w-full max-w-md shadow-xl">
|
||
<form onSubmit={submit} className="p-6 space-y-4">
|
||
<div className="text-xl font-semibold">New Attendee</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Full name *</label>
|
||
<input
|
||
className="w-full border rounded px-3 py-2 text-sm"
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
placeholder="Full name"
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Email</label>
|
||
<input
|
||
className="w-full border rounded px-3 py-2 text-sm"
|
||
type="email"
|
||
value={email}
|
||
onChange={e => setEmail(e.target.value)}
|
||
placeholder="Email address"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Cell number</label>
|
||
<input
|
||
className="w-full border rounded px-3 py-2 text-sm"
|
||
type="tel"
|
||
value={phone}
|
||
onChange={e => {
|
||
const v = e.target.value;
|
||
setPhone(v);
|
||
if (v.trim() && !email.trim()) setNotifPref("whatsapp");
|
||
else if (!v.trim() && email.trim()) setNotifPref("email");
|
||
}}
|
||
placeholder="+27…"
|
||
/>
|
||
</div>
|
||
|
||
{/* Preference selector — shown only when both channels are provided */}
|
||
{email.trim() && phone.trim() && (
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">Send tickets via</label>
|
||
<div className="flex rounded-lg border overflow-hidden text-xs font-medium">
|
||
{(["email", "whatsapp", "both"] as const).map((p) => (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
onClick={() => setNotifPref(p)}
|
||
className={`flex-1 py-2 transition-colors ${
|
||
notifPref === p
|
||
? p === "whatsapp" ? "bg-green-600 text-white"
|
||
: p === "both" ? "bg-brand-600 text-white"
|
||
: "bg-blue-600 text-white"
|
||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
{p === "email" ? "Email" : p === "whatsapp" ? "WhatsApp" : "Both"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created.</p>
|
||
|
||
{err && <p className="text-sm text-red-600">{err}</p>}
|
||
|
||
<div className="flex gap-2 pt-2">
|
||
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded text-sm">Cancel</button>
|
||
<button type="submit" className="flex-1 py-2 rounded bg-emerald-600 text-white text-sm font-medium">Next — Choose Items</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|