Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
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] = useState<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
|
||||
export default function StaffDashboardPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Access control (staff/supervisor/admin)
|
||||
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, router]);
|
||||
|
||||
// Stats and recent scans for quick overview — both come from one endpoint
|
||||
// (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when
|
||||
// a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows
|
||||
// on the very first load — together these stop the stats panel from flickering.
|
||||
const [stats, setStats] = useStableState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const data = await apiFetch<any>("/api/stats/staff", { authToken: token });
|
||||
setStats(data.scanStats);
|
||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
|
||||
// Poll every 10s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
useVisiblePolling(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, 10000, !!token);
|
||||
|
||||
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">Staff Dashboard{user ? ` — ${user.name}` : ""}</h1>
|
||||
<div className="hidden sm:flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!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 use staff tools.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-2">Quick actions</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Scan tickets
|
||||
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
|
||||
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || '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'} — #{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>
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg 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-1">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"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 { useRouter } from "next/navigation";
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
const scannerRef = useRef<QRScannerHandle>(null);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
const [scanInfo, setScanInfo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<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">
|
||||
<h1 className="text-2xl font-semibold">Ticket Scanning</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">
|
||||
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-indigo-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-indigo-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-indigo-600 text-white text-sm font-semibold hover:bg-indigo-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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user