Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar shell, per-page help guides, and a layout/content pass across every remaining page) plus backend fixes to the dashboard KPI stats: - Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) now use a rolling trailing-month window (today back one calendar month, e.g. 9 May - 8 June if today is 8 June) instead of calendar month-to-date, which under-counted for most of the month. The comparison window shifts the same way, so like is still compared with like. - Reports deep-links from those stat tiles now match the same window (range=trailing_month, replacing range=this_month). - Design tokens (brand-* Tailwind scale + shadcn CSS variables), a site-wide contextual help button, fixed dashboard sidebar/navbar, Admin/Supervisor/Staff/User dashboard rebuilds backed by a new GET /api/stats/overview endpoint, a dedicated Contact page, Site Settings restyle with WhatsApp config folded in, and an Account activity feed backed by a new SecurityEvent model. - Every remaining page (home, events, registration flow, auth, legal, payment results, and every Admin/Supervisor/Staff/User tool page) restyled onto the same design tokens, several with real layout upgrades (home hero, events list/detail, donate page, auth pages). - 20+ new dedicated help guides so the whole site has page-specific help content instead of falling back to a generic guide. - Assorted fixes surfaced along the way: donation-leg double-counting in payment stats, donations not counting toward revenue, refund netting in per-method report breakdowns, and donation over-allocation after a refund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
494 lines
22 KiB
TypeScript
494 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { apiFetch } from "@/lib/api";
|
||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||
import { useRouter } from "next/navigation";
|
||
import { QrCode } from "lucide-react";
|
||
|
||
export default function TicketScanningPage() {
|
||
const router = useRouter();
|
||
const scannerRef = useRef<QRScannerHandle>(null);
|
||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
|
||
const [error, setError] = useDismissingState<string | null>(null);
|
||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||
const { token, user } = useAuth();
|
||
const [recentScans, setRecentScans] = useState<any[]>([]);
|
||
const [stats, setStats] = useState<any | null>(null);
|
||
const [loadingStats, setLoadingStats] = useState<boolean>(false);
|
||
const [successModal, setSuccessModal] = useState<{
|
||
optionName: string;
|
||
ticketId: string;
|
||
eventTitle: string;
|
||
qtyRedeemed: number;
|
||
remaining: number;
|
||
total: number;
|
||
} | null>(null);
|
||
|
||
// Confirm step state
|
||
const [confirmModal, setConfirmModal] = useState<{
|
||
qrCode: string;
|
||
ticket: any;
|
||
remaining: number;
|
||
} | null>(null);
|
||
const [confirmQty, setConfirmQty] = useState(1);
|
||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||
|
||
// Event selection state
|
||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||
const [sections, setSections] = useState<any[]>([]);
|
||
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
|
||
const [loadingSections, setLoadingSections] = useState(false);
|
||
|
||
// All events from API (including past); frontend filters to 12h window or all past
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
if (!token) return;
|
||
setLoadingEvents(true);
|
||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
|
||
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
|
||
setAllEvents(sorted);
|
||
} catch (e: any) {
|
||
// non-fatal
|
||
} finally {
|
||
setLoadingEvents(false);
|
||
}
|
||
})();
|
||
}, [token]);
|
||
|
||
const events = useMemo(() => {
|
||
if (includePastEvents) return allEvents;
|
||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
||
return allEvents.filter(ev => {
|
||
const t = new Date(ev.endDate).getTime();
|
||
return !isNaN(t) && t >= cutoff;
|
||
});
|
||
}, [allEvents, includePastEvents]);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
if (!token || !selectedEventId || selectedEventId === "all") {
|
||
setSections([]);
|
||
setSelectedSectionId("all");
|
||
return;
|
||
}
|
||
try {
|
||
setLoadingSections(true);
|
||
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||
setSections(Array.isArray(list) ? list : []);
|
||
setSelectedSectionId("all");
|
||
} catch {
|
||
setSections([]);
|
||
} finally {
|
||
setLoadingSections(false);
|
||
}
|
||
})();
|
||
}, [selectedEventId, token]);
|
||
|
||
// Bug fix: stop the scanner when the event or section filter changes so stale scan
|
||
// context is never used. The user must press "Scan Ticket" again to resume.
|
||
const isFirstFilterRender = useRef(true);
|
||
useEffect(() => {
|
||
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
|
||
scannerRef.current?.stop();
|
||
}, [selectedEventId, selectedSectionId]);
|
||
|
||
async function handleResult(text: string) {
|
||
setLastResult(text);
|
||
setError(null);
|
||
setScanInfo(null);
|
||
try {
|
||
if (!token) {
|
||
setErrorModal("Please login as staff to scan tickets.");
|
||
return;
|
||
}
|
||
|
||
// Always preview first to validate the ticket before showing confirm step
|
||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||
let preview: any;
|
||
try {
|
||
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||
} catch (pe: any) {
|
||
setErrorModal(pe?.message || 'Failed to validate ticket');
|
||
return;
|
||
}
|
||
|
||
// Validate event
|
||
if (selectedEventId && selectedEventId !== "all") {
|
||
const ticketEventId = preview?.event?.id || preview?.eventId;
|
||
if (ticketEventId && ticketEventId !== selectedEventId) {
|
||
const eventTitle = preview?.event?.title || 'Event';
|
||
setErrorModal(`This ticket is for a different event: ${eventTitle}`);
|
||
return;
|
||
}
|
||
|
||
// Validate section
|
||
if (selectedSectionId !== "all") {
|
||
const ticketOptionId = preview?.registrationOption?.eventOption?.id || preview?.eventOptionId;
|
||
if (!ticketOptionId) {
|
||
setErrorModal("Unable to determine ticket type");
|
||
return;
|
||
}
|
||
const section = sections.find(s => s.id === selectedSectionId);
|
||
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
|
||
if (!allowedOptionIds.includes(ticketOptionId)) {
|
||
setErrorModal("This ticket is not valid for this section");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compute remaining qty
|
||
const ticketQty = preview?.quantity || 1;
|
||
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||
const remaining = ticketQty - totalRedeemed;
|
||
|
||
if (remaining <= 0) {
|
||
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview });
|
||
return;
|
||
}
|
||
|
||
// Show confirm step
|
||
setConfirmQty(remaining);
|
||
setConfirmQtyRaw(String(remaining));
|
||
setConfirmModal({ qrCode: text, ticket: preview, remaining });
|
||
} catch (e: any) {
|
||
setErrorModal(e?.message || "Failed to scan ticket");
|
||
}
|
||
}
|
||
|
||
async function commitScan() {
|
||
if (!confirmModal || !token) return;
|
||
|
||
// Capture everything before clearing modal state
|
||
const { qrCode, ticket, remaining } = confirmModal;
|
||
const qty = confirmQty;
|
||
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
|
||
const variantName = ticket?.registrationOption?.variant?.name;
|
||
const optionName = variantName ? `${baseName} — ${variantName}` : baseName;
|
||
const eventTitle = ticket?.event?.title || 'Event';
|
||
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
|
||
const newRemaining = remaining - qty;
|
||
|
||
// Show success immediately — we already have all the data from the preview step.
|
||
// The actual write happens in the background so the user sees an instant response.
|
||
setConfirmModal(null);
|
||
setScanInfo(`Ticket #${idPart} — ${optionName} — ${eventTitle}`);
|
||
setSuccessModal({
|
||
optionName,
|
||
ticketId: idPart,
|
||
eventTitle,
|
||
qtyRedeemed: qty,
|
||
remaining: newRemaining,
|
||
total: ticket?.quantity || 1,
|
||
});
|
||
|
||
// Background write — reverse the optimistic update on failure
|
||
try {
|
||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
|
||
}`;
|
||
await apiFetch<any>(url, {
|
||
method: "POST",
|
||
authToken: token,
|
||
body: { qty },
|
||
});
|
||
refreshStatsAndRecent();
|
||
} catch (e: any) {
|
||
setSuccessModal(null);
|
||
setScanInfo(null);
|
||
const raw = e?.message || "Failed to scan ticket";
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
const msg: string | undefined = parsed?.message;
|
||
if (msg && /already\s*(been)?\s*used/i.test(msg)) {
|
||
setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket });
|
||
return;
|
||
}
|
||
setErrorModal(msg || raw);
|
||
} catch {
|
||
setErrorModal(raw);
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadRecent = async () => {
|
||
if (!token) return;
|
||
try {
|
||
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||
setRecentScans(list);
|
||
} catch {}
|
||
};
|
||
const loadStats = async () => {
|
||
if (!token) return;
|
||
try {
|
||
setLoadingStats(true);
|
||
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
|
||
setStats(s);
|
||
} catch {} finally {
|
||
setLoadingStats(false);
|
||
}
|
||
};
|
||
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
|
||
useEffect(() => {
|
||
refreshStatsAndRecent();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [token]);
|
||
|
||
return (
|
||
<div className="max-w-6xl mx-auto w-full p-4">
|
||
<div className="grid lg:grid-cols-3 gap-6">
|
||
<div className="lg:col-span-2">
|
||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||
<QrCode className="w-5 h-5 text-brand-600" />
|
||
</div>
|
||
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</h1>
|
||
</div>
|
||
<button className="px-3 py-1.5 text-sm rounded-lg 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">
|
||
Use the button to start/stop scanning. The back camera will be used when available.
|
||
</p>
|
||
|
||
{/* Event filter */}
|
||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
|
||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||
<select
|
||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||
value={selectedEventId}
|
||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||
>
|
||
<option value="all">All events</option>
|
||
{events.map((ev) => (
|
||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||
))}
|
||
</select>
|
||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
|
||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
|
||
Include past events
|
||
</label>
|
||
{loadingEvents && <span className="text-xs text-gray-500">Loading events…</span>}
|
||
</div>
|
||
{sections.length > 0 && (
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
|
||
<label className="text-sm text-gray-700 shrink-0">Section</label>
|
||
<select
|
||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||
value={selectedSectionId}
|
||
onChange={(e) => setSelectedSectionId(e.target.value)}
|
||
>
|
||
<option value="all">All sections</option>
|
||
{sections.map(section => (
|
||
<option key={section.id} value={section.id}>{section.name}</option>
|
||
))}
|
||
</select>
|
||
{loadingSections && <span className="text-xs text-gray-500">Loading sections…</span>}
|
||
</div>
|
||
)}
|
||
|
||
<QRScanner
|
||
ref={scannerRef}
|
||
onResult={handleResult}
|
||
paused={!!(confirmModal || alreadyUsedModal || errorModal || successModal)}
|
||
/>
|
||
|
||
{scanInfo && (
|
||
<div className="mt-2 p-3 border rounded bg-green-50 text-green-800 text-sm">{scanInfo}</div>
|
||
)}
|
||
{error && (
|
||
<div className="mt-2 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<h2 className="text-xl font-semibold mb-3">Scanner Stats</h2>
|
||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats…</div>}
|
||
{stats && (
|
||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||
<div className="border rounded p-3 bg-white">
|
||
<div className="text-xs text-gray-500">Today</div>
|
||
<div className="text-lg font-semibold">{stats.totalToday}</div>
|
||
</div>
|
||
<div className="border rounded p-3 bg-white">
|
||
<div className="text-xs text-gray-500">My scans</div>
|
||
<div className="text-lg font-semibold">{stats.myToday}</div>
|
||
</div>
|
||
<div className="border rounded p-3 bg-white">
|
||
<div className="text-xs text-gray-500">Last hour</div>
|
||
<div className="text-lg font-semibold">{stats.lastHour}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{stats?.byStaff?.length > 0 && (
|
||
<div className="mb-4">
|
||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||
<ul className="text-sm text-gray-700 space-y-1">
|
||
{stats.byStaff.map((s: any) => (
|
||
<li key={s.scannedById} className="flex justify-between">
|
||
<span>{s.name || 'Staff'}</span>
|
||
<span className="font-medium">{s.count}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
|
||
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
|
||
{recentScans.map((u: any) => (
|
||
<li key={u.id} className="border rounded p-2 bg-white">
|
||
<div className="flex justify-between">
|
||
<div className="font-medium">{u.ticket?.event?.title || 'Event'}</div>
|
||
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
|
||
</div>
|
||
<div className="text-xs text-gray-600">
|
||
{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{u.ticket?.registrationOption?.variant?.name ? ` — ${u.ticket.registrationOption.variant.name}` : ''}
|
||
{u.quantityRedeemed && u.quantityRedeemed > 1 ? ` ×${u.quantityRedeemed}` : ''}
|
||
{' — '}#{String(u.ticket?.id || '').slice(0,8)}
|
||
</div>
|
||
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
|
||
</li>
|
||
))}
|
||
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Confirm scan modal ───────────────────────────────────────────── */}
|
||
{confirmModal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
|
||
<h3 className="text-lg font-semibold mb-1 text-brand-700">Confirm Scan</h3>
|
||
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
|
||
|
||
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
|
||
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
|
||
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
|
||
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
|
||
<div><span className="font-medium">Qty on ticket:</span> {confirmModal.ticket?.quantity || 1}</div>
|
||
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
|
||
</div>
|
||
|
||
{(confirmModal.ticket?.quantity || 1) > 1 && (
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Qty to redeem (max {confirmModal.remaining})</label>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
min={1}
|
||
max={confirmModal.remaining}
|
||
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||
value={confirmQtyRaw}
|
||
onChange={e => setConfirmQtyRaw(e.target.value)}
|
||
onBlur={() => {
|
||
const parsed = parseInt(confirmQtyRaw, 10);
|
||
const clamped = isNaN(parsed) ? 1 : Math.min(confirmModal.remaining, Math.max(1, parsed));
|
||
setConfirmQty(clamped);
|
||
setConfirmQtyRaw(String(clamped));
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => setConfirmModal(null)}
|
||
className="flex-1 px-4 py-2 rounded-lg border text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={commitScan}
|
||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||
>
|
||
Confirm Scan
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Already used modal ───────────────────────────────────────────── */}
|
||
{alreadyUsedModal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
|
||
<h3 className="text-lg font-semibold mb-2 text-red-600">Ticket Already Scanned</h3>
|
||
<p className="text-sm text-gray-700 mb-3">{alreadyUsedModal.message}</p>
|
||
{alreadyUsedModal.ticket && (
|
||
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded border">
|
||
<div><span className="font-medium">Ticket ID:</span> {alreadyUsedModal.ticket.id}</div>
|
||
{alreadyUsedModal.ticket.event?.title && (
|
||
<div><span className="font-medium">Event:</span> {alreadyUsedModal.ticket.event.title}</div>
|
||
)}
|
||
{Array.isArray(alreadyUsedModal.ticket.usages) && alreadyUsedModal.ticket.usages.length > 0 && (
|
||
<div className="mt-2">
|
||
<div className="font-medium">Usages:</div>
|
||
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
|
||
{alreadyUsedModal.ticket.usages.map((u: any) => (
|
||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="mt-4 flex justify-end">
|
||
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setAlreadyUsedModal(null)}>OK</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Error modal ──────────────────────────────────────────────────── */}
|
||
{errorModal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
|
||
<h3 className="text-lg font-semibold mb-2 text-red-600">Scan Error</h3>
|
||
<p className="text-sm text-gray-700 mb-3">{errorModal}</p>
|
||
<div className="mt-4 flex justify-end">
|
||
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setErrorModal(null)}>OK</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Success modal ────────────────────────────────────────────────── */}
|
||
{successModal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
|
||
<h3 className="text-lg font-semibold mb-4 text-green-600">Scan Successful</h3>
|
||
|
||
<div className="text-3xl font-bold text-center text-gray-900 mb-2">
|
||
{successModal.optionName}
|
||
</div>
|
||
|
||
<div className="text-sm text-center text-gray-600 mb-3">
|
||
#{successModal.ticketId} — {successModal.eventTitle}
|
||
</div>
|
||
|
||
{successModal.total > 1 && (
|
||
<div className={`text-center text-sm font-medium mb-4 ${successModal.remaining > 0 ? 'text-amber-600' : 'text-green-700'}`}>
|
||
{successModal.qtyRedeemed} redeemed · {successModal.remaining} remaining of {successModal.total}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-center">
|
||
<button className="px-6 py-2 rounded bg-green-600 text-white" onClick={() => setSuccessModal(null)}>OK</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |