Two consistency fixes requested after the payment-method work: 1. Registration status (pending/confirmed/partial_paid/paid/cancelled) was printed as a raw string on the user dashboard. Added RegistrationStatusBadge mirroring the existing EventStatusBadge pattern, using the same status colors already established on dashboard/admin/registrations. 2. Inline success/error banners across dashboard pages persisted indefinitely. Added a shared useDismissingState hook (drop-in useState replacement that auto-clears a truthy value after 7s, resetting the timer on each update) and swapped it in across ~24 dashboard files. Excluded: message-only modal dialogs (ticket- scanning's success/error confirmations) and two states that mix live form-validation feedback with async results inside actively- open forms (the registration-edit modal's editError, the event create/edit modal's error) - those keep persisting until the user acts, since auto-hiding a "fix this field" message mid-edit would be a regression. Also fixed at-the-door's existing bespoke auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same consistent 7s, and removed admin/settings' manual x dismiss button in favor of the same auto-only behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { apiFetch } from "@/lib/api";
|
||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { formatDate } from "@/lib/date";
|
||
import { QrImage } from "@/components/shared/QrImage";
|
||
|
||
function EventTicketsContent() {
|
||
const { token, user } = useAuth();
|
||
const params = useSearchParams();
|
||
const router = useRouter();
|
||
|
||
const paramEventId = params.get("eventId") || "";
|
||
const paramUserId = params.get("userId") || "";
|
||
|
||
const [events, setEvents] = useState<any[]>([]);
|
||
const [eventId, setEventId] = useState<string>(paramEventId);
|
||
const [tickets, setTickets] = useState<any[]>([]);
|
||
const [error, setError] = useDismissingState<string | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
|
||
const [selectedVariantId, setSelectedVariantId] = useState<string>("");
|
||
|
||
const canView = useMemo(() => {
|
||
const role = user?.role;
|
||
return role === "admin" || role === "supervisor" || role === "staff";
|
||
}, [user]);
|
||
|
||
// Load events for dropdown
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
if (!token) return;
|
||
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();
|
||
return !isNaN(t) && t > now;
|
||
});
|
||
active.sort(
|
||
(a, b) =>
|
||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||
);
|
||
setEvents(active);
|
||
if (!paramEventId && active.length > 0) {
|
||
setEventId(active[0].id);
|
||
router.replace(
|
||
`?eventId=${encodeURIComponent(active[0].id)}${
|
||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||
}`
|
||
);
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message || "Failed to load events");
|
||
}
|
||
})();
|
||
}, [token]);
|
||
|
||
// Sync params → state
|
||
useEffect(() => {
|
||
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
|
||
if (paramUserId && paramUserId !== selectedUserId)
|
||
setSelectedUserId(paramUserId);
|
||
}, [paramEventId, paramUserId]);
|
||
|
||
// Load tickets when event changes
|
||
useEffect(() => {
|
||
(async () => {
|
||
if (!token || !eventId) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const list = await apiFetch<any[]>(
|
||
`/api/tickets/event/${encodeURIComponent(eventId)}`,
|
||
{ authToken: token }
|
||
);
|
||
setTickets(list);
|
||
} catch (e: any) {
|
||
setError(e?.message || "Failed to load tickets");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, [token, eventId]);
|
||
|
||
// Build variants list for filter (only variants present in current event's tickets)
|
||
const variantsList = useMemo(() => {
|
||
const map = new Map<string, string>();
|
||
for (const t of tickets) {
|
||
const v = t.registrationOption?.variant;
|
||
if (v?.id) map.set(v.id, v.name);
|
||
}
|
||
return Array.from(map.entries())
|
||
.map(([id, name]) => ({ id, name }))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
}, [tickets]);
|
||
|
||
// Build users list depending on event selection
|
||
const usersList = useMemo(() => {
|
||
let source = [...tickets];
|
||
if (eventId) {
|
||
source = source.filter((t) => String(t.eventId) === String(eventId));
|
||
}
|
||
const map = new Map<string, string>();
|
||
for (const t of source) {
|
||
const uid = t.user?.id || t.userId;
|
||
if (!uid) continue;
|
||
const name = t.user?.name || t.user?.email || String(uid);
|
||
if (!map.has(String(uid))) map.set(String(uid), name);
|
||
}
|
||
return Array.from(map.entries())
|
||
.map(([id, name]) => ({ id, name }))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
}, [tickets, eventId]);
|
||
|
||
// Filter tickets by event + user + variant
|
||
const filteredTickets = useMemo(() => {
|
||
let list = [...tickets];
|
||
if (eventId) {
|
||
list = list.filter((t) => String(t.eventId) === String(eventId));
|
||
}
|
||
if (selectedUserId) {
|
||
list = list.filter(
|
||
(t) => String(t.user?.id || t.userId) === String(selectedUserId)
|
||
);
|
||
}
|
||
if (selectedVariantId) {
|
||
list = list.filter(
|
||
(t) => String(t.registrationOption?.variant?.id || "") === String(selectedVariantId)
|
||
);
|
||
}
|
||
return list;
|
||
}, [tickets, eventId, selectedUserId, selectedVariantId]);
|
||
|
||
// Printing helpers
|
||
const buildTicketHtmlCard = (t: any) => {
|
||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
|
||
const optVariant = t.registrationOption?.variant?.name;
|
||
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
|
||
const qty = t.quantity || 1;
|
||
const holder = t.user?.name || t.userId || "";
|
||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
|
||
t.qrCode || t.id
|
||
)}`;
|
||
const dateStr = eventDate ? formatDate(eventDate) : "";
|
||
return `
|
||
<div class="ticket">
|
||
<div class="evt">${eventTitle}</div>
|
||
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
|
||
<div class="type">${type}</div>
|
||
<div class="qty">Qty: <strong>${qty}</strong></div>
|
||
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
||
<div class="meta">ID: ${t.id}</div>
|
||
${holder ? `<div class="meta">${holder}</div>` : ""}
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
const openPrintWindow = (pagesHtml: string) => {
|
||
const w = window.open("", "_blank");
|
||
if (!w) return;
|
||
w.document.write(`<!doctype html><html><head><title>Tickets</title>
|
||
<style>
|
||
@page { size: A4; margin: 10mm; }
|
||
* { box-sizing: border-box; }
|
||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
|
||
.page {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
grid-template-rows: repeat(4, 1fr);
|
||
height: 277mm;
|
||
gap: 6mm;
|
||
page-break-after: always;
|
||
break-after: page;
|
||
}
|
||
.page:last-child { page-break-after: avoid; break-after: avoid; }
|
||
.ticket { border: 1px solid #000; padding: 5mm; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
|
||
.evt { font-weight: bold; font-size: 14px; }
|
||
.type { font-size: 12px; margin-top: 2mm; }
|
||
.qty { font-size: 11px; margin-top: 1mm; }
|
||
.date { font-size: 10px; color: #555; margin-top: 1mm; }
|
||
.qrwrap { display: flex; justify-content: center; margin: 2mm 0; }
|
||
.qrwrap img { width: 30mm; height: 30mm; }
|
||
.meta { font-size: 10px; text-align: center; }
|
||
</style>
|
||
</head><body>
|
||
${pagesHtml}
|
||
<script>
|
||
(function(){
|
||
function printWhenReady(){
|
||
var imgs = Array.prototype.slice.call(document.images);
|
||
if(imgs.length === 0){ window.print(); return; }
|
||
var remaining = imgs.length;
|
||
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 100); } }
|
||
imgs.forEach(function(img){
|
||
if (img.complete) { done(); }
|
||
else {
|
||
img.addEventListener('load', done, { once: true });
|
||
img.addEventListener('error', done, { once: true });
|
||
}
|
||
});
|
||
}
|
||
if (document.readyState === 'complete') printWhenReady();
|
||
else window.addEventListener('load', printWhenReady);
|
||
})();
|
||
</script>
|
||
</body></html>`);
|
||
w.document.close();
|
||
w.focus();
|
||
};
|
||
|
||
const printTickets = (ticketList: any[]) => {
|
||
if (!ticketList || ticketList.length === 0) return;
|
||
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
|
||
const PAGE_SIZE = 8;
|
||
const pages: string[] = [];
|
||
for (let i = 0; i < ticketList.length; i += PAGE_SIZE) {
|
||
const chunk = ticketList.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-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h1 className="text-2xl font-semibold">Event Tickets</h1>
|
||
<button
|
||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
|
||
onClick={() => router.push("/dashboard")}
|
||
>
|
||
Back
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
View and print tickets for an event.
|
||
</p>
|
||
|
||
{!canView && (
|
||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||
You need staff, supervisor, or admin access to view this page.
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
|
||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||
<select
|
||
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
|
||
value={eventId}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
setEventId(v);
|
||
setSelectedUserId("");
|
||
router.replace(
|
||
`?eventId=${encodeURIComponent(v)}${
|
||
selectedUserId
|
||
? `&userId=${encodeURIComponent(selectedUserId)}`
|
||
: ""
|
||
}`
|
||
);
|
||
}}
|
||
>
|
||
<option value="">All events</option>
|
||
{events.map((ev) => (
|
||
<option key={ev.id} value={ev.id}>
|
||
{ev.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
|
||
<select
|
||
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
|
||
value={selectedUserId}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
setSelectedUserId(v);
|
||
router.replace(
|
||
`?eventId=${encodeURIComponent(eventId || "")}${
|
||
v ? `&userId=${encodeURIComponent(v)}` : ""
|
||
}`
|
||
);
|
||
}}
|
||
disabled={usersList.length === 0}
|
||
>
|
||
<option value="">All users</option>
|
||
{usersList.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{variantsList.length > 0 && (
|
||
<>
|
||
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">Variant</label>
|
||
<select
|
||
className="border rounded px-3 py-2 w-full sm:w-48 max-w-full"
|
||
value={selectedVariantId}
|
||
onChange={(e) => setSelectedVariantId(e.target.value)}
|
||
>
|
||
<option value="">All variants</option>
|
||
{variantsList.map((v) => (
|
||
<option key={v.id} value={v.id}>{v.name}</option>
|
||
))}
|
||
</select>
|
||
</>
|
||
)}
|
||
|
||
{filteredTickets.length > 0 && (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => printTickets(filteredTickets)}
|
||
className="px-3 py-2 border rounded"
|
||
>
|
||
Print all
|
||
</button>
|
||
<button
|
||
onClick={() =>
|
||
printTickets(filteredTickets.filter((t) => !t.isUsed))
|
||
}
|
||
className="px-3 py-2 bg-blue-600 text-white rounded"
|
||
>
|
||
Print unused
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{loading && <div>Loading...</div>}
|
||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||
|
||
{filteredTickets.length > 0 && (
|
||
<div className="mb-3 text-sm text-gray-700">
|
||
<span className="mr-3">
|
||
Total tickets: {filteredTickets.length}
|
||
</span>
|
||
<span className="mr-3">
|
||
Used: {filteredTickets.filter((t) => t.isUsed).length}
|
||
</span>
|
||
<span>
|
||
Unused: {filteredTickets.filter((t) => !t.isUsed).length}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{filteredTickets.map((t) => {
|
||
const eventDate = t.event?.startDate
|
||
? new Date(t.event.startDate)
|
||
: null;
|
||
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
|
||
const optVariant = t.registrationOption?.variant?.name;
|
||
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
|
||
return (
|
||
<div
|
||
key={t.id}
|
||
className="border rounded-xl p-3 space-y-2 bg-white shadow-sm"
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="font-medium">
|
||
{t.event?.title || t.eventId}
|
||
</div>
|
||
{eventDate && (
|
||
<span className="text-xs text-gray-500">
|
||
{formatDate(eventDate)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-sm text-gray-700">{type}</div>
|
||
<div className="flex justify-center">
|
||
<QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" />
|
||
</div>
|
||
<div className="text-center text-xs text-gray-500">
|
||
#{String(t.id).slice(0, 8)} •{" "}
|
||
{t.isUsed ? "Used" : "Unused"}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function EventTicketsPage() {
|
||
return (
|
||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||
<EventTicketsContent />
|
||
</Suspense>
|
||
);
|
||
}
|