Add door check-in flow for Main Tickets
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>
This commit is contained in:
@@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
|
||||
type Mode = "registration" | "payment" | "tickets" | "refund";
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -159,8 +167,7 @@ export default function AtTheDoorPage() {
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
|
||||
const handleRegistrationCreated = (registration: any) => {
|
||||
setActiveRegistration(registration);
|
||||
setMode("payment"); // 🚀 Jump automatically
|
||||
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
|
||||
};
|
||||
|
||||
const handleRegistrationSelected = (registration: any) => {
|
||||
@@ -171,7 +178,9 @@ export default function AtTheDoorPage() {
|
||||
if (!activeRegistration) return;
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setMode("payment");
|
||||
// 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);
|
||||
@@ -317,28 +326,12 @@ export default function AtTheDoorPage() {
|
||||
|
||||
const reg = registration || activeRegistration;
|
||||
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/tickets/generate", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: reg.id
|
||||
}
|
||||
});
|
||||
|
||||
const tickets = res?.tickets || [];
|
||||
|
||||
if (tickets.length) {
|
||||
printTickets(tickets);
|
||||
}
|
||||
|
||||
setInfo("Payment recorded & tickets printed");
|
||||
setActiveRegistration(null);
|
||||
setMode("registration");
|
||||
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Tickets failed to generate");
|
||||
}
|
||||
// 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) => {
|
||||
@@ -346,97 +339,6 @@ export default function AtTheDoorPage() {
|
||||
setShowDonationModal(true);
|
||||
};
|
||||
|
||||
const buildTicketHtmlCard = (t: any) => {
|
||||
const eventTitle =
|
||||
t.event?.title ||
|
||||
t.registrationOption?.registration?.event?.title ||
|
||||
"Event";
|
||||
|
||||
const ticketType = ticketLabel(t);
|
||||
const qty = t.quantity || 1;
|
||||
const holder =
|
||||
t.user?.name ||
|
||||
t.registrationOption?.registration?.user?.name ||
|
||||
"";
|
||||
const qrValue = t.qrCode || t.id;
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
|
||||
|
||||
return `<div class="ticket">
|
||||
<div class="top">
|
||||
<div class="evt">${eventTitle}</div>
|
||||
<div class="type">${ticketType}</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">${t.id}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const openPrintWindow = (cardsHtml: string) => {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
w.document.write(`<!doctype html><html><head><style>
|
||||
@page { size: A4; margin: 8mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
width: 194mm;
|
||||
height: 281mm;
|
||||
gap: 4mm;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
.page:last-child { page-break-after: avoid; break-after: avoid; }
|
||||
.ticket {
|
||||
border: 1.5px solid #222;
|
||||
border-radius: 3mm;
|
||||
padding: 5mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.top { flex: 0 0 auto; }
|
||||
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
|
||||
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
|
||||
.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 1 auto; padding: 3mm 0; }
|
||||
.qrwrap img { width: 48mm; height: 48mm; display: block; }
|
||||
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
|
||||
</style></head><body>${cardsHtml}<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();
|
||||
};
|
||||
|
||||
const printTickets = (tickets: any[]) => {
|
||||
if (!tickets?.length) return;
|
||||
// 4 per A4 page (2 columns × 2 rows)
|
||||
const PAGE_SIZE = 4;
|
||||
const pages: string[] = [];
|
||||
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
|
||||
const chunk = tickets.slice(i, i + PAGE_SIZE);
|
||||
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
|
||||
}
|
||||
openPrintWindow(pages.join(""));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||
|
||||
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() {
|
||||
|
||||
{/* Mode Buttons */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => (
|
||||
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
@@ -494,7 +396,7 @@ export default function AtTheDoorPage() {
|
||||
: "bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{m.toUpperCase()}
|
||||
{MODE_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "checkin" && (
|
||||
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
|
||||
)}
|
||||
|
||||
{mode === "tickets" && (
|
||||
<DoorTicketsPanel token={token} eventId={eventId} />
|
||||
)}
|
||||
@@ -1081,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user