Lets staff redeem a registration's Main Tickets by quantity at the door (via the Payment/registration flow) instead of scanning each QR code, and automatically emails/WhatsApps a check-in confirmation to the attendee. Also routes the At The Door "Open" button and post-payment flow dynamically: paid registrations jump straight to Check-In instead of a forced ticket print, since tickets are already sent automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1900 lines
81 KiB
TypeScript
1900 lines
81 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";
|
||
|
||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||
|
||
const MODE_LABELS: Record<Mode, string> = {
|
||
registration: "REGISTRATION",
|
||
payment: "PAYMENT",
|
||
checkin: "CHECK-IN",
|
||
tickets: "TICKETS",
|
||
refund: "REFUND",
|
||
};
|
||
|
||
// ─── 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">
|
||
<h1 className="text-2xl font-semibold shrink-0">At The Door</h1>
|
||
<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 => (
|
||
<button
|
||
key={m}
|
||
onClick={() => setMode(m)}
|
||
className={`px-4 py-2 rounded text-sm font-medium border ${
|
||
mode === m
|
||
? m === "refund"
|
||
? "bg-red-600 text-white border-red-600"
|
||
: "bg-indigo-600 text-white border-indigo-600"
|
||
: "bg-white hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
{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-indigo-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-indigo-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-indigo-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-indigo-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-blue-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-indigo-600 text-white border-indigo-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-indigo-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-indigo-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>
|
||
);
|
||
}
|