317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
/**
|
||
* Check-in station — scan QR code or search bidder by name / paddle number.
|
||
* Uses the browser BarcodeDetector API for camera scanning (Chrome/Edge mobile).
|
||
* Falls back to manual search if BarcodeDetector is unavailable.
|
||
*/
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { api } from "../../lib/api.js";
|
||
|
||
interface BidderEnrollment {
|
||
id: string;
|
||
bidderId: string;
|
||
paddleNumber: number | null;
|
||
checkInStatus: string;
|
||
checkInAt: string | null;
|
||
bidder: {
|
||
id: string;
|
||
firstName: string;
|
||
lastName: string;
|
||
email: string | null;
|
||
phone: string | null;
|
||
};
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
BarcodeDetector?: new (opts: { formats: string[] }) => {
|
||
detect: (source: HTMLVideoElement) => Promise<{ rawValue: string }[]>;
|
||
};
|
||
}
|
||
}
|
||
|
||
type CheckInResult = { enrollment: BidderEnrollment; alreadyCheckedIn: boolean } | null;
|
||
|
||
export default function CheckInPage() {
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<BidderEnrollment[]>([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [checkInResult, setCheckInResult] = useState<CheckInResult>(null);
|
||
const [scanMode, setScanMode] = useState(false);
|
||
const [scanError, setScanError] = useState<string | null>(null);
|
||
const [eventId, setEventId] = useState<string | null>(null);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const scanLoopRef = useRef<number | null>(null);
|
||
|
||
// Fetch active event for this staff session
|
||
useEffect(() => {
|
||
api
|
||
.get<{ id: string; status: string }[]>("/api/events")
|
||
.then((events) => {
|
||
const active = events.find((e) => e.status === "active") ?? events[0];
|
||
if (active) setEventId(active.id);
|
||
})
|
||
.catch(console.error);
|
||
}, []);
|
||
|
||
// Debounced bidder search
|
||
useEffect(() => {
|
||
if (!eventId || query.trim().length < 2) {
|
||
setResults([]);
|
||
return;
|
||
}
|
||
setSearching(true);
|
||
const timer = setTimeout(() => {
|
||
api
|
||
.get<BidderEnrollment[]>(`/api/bidders?eventId=${eventId}&q=${encodeURIComponent(query)}`)
|
||
.then(setResults)
|
||
.catch(console.error)
|
||
.finally(() => setSearching(false));
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [query, eventId]);
|
||
|
||
const doCheckIn = useCallback(
|
||
async (enrollmentId: string) => {
|
||
try {
|
||
const result = await api.post<{ enrollment: BidderEnrollment; alreadyCheckedIn: boolean }>(
|
||
`/api/check-in/${enrollmentId}`,
|
||
{},
|
||
);
|
||
setCheckInResult(result);
|
||
setResults([]);
|
||
setQuery("");
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
const doScanCheckIn = useCallback(
|
||
async (bidderId: string, eid: string) => {
|
||
try {
|
||
const result = await api.post<{ enrollment: BidderEnrollment; alreadyCheckedIn: boolean }>(
|
||
"/api/check-in/scan",
|
||
{ bidderId, eventId: eid },
|
||
);
|
||
setCheckInResult(result);
|
||
} catch {
|
||
setScanError("Bidder not found for this event");
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// Camera QR scanning loop
|
||
const startScan = useCallback(async () => {
|
||
if (!window.BarcodeDetector) {
|
||
setScanError("Camera QR scanning not supported in this browser. Use manual search.");
|
||
return;
|
||
}
|
||
setScanMode(true);
|
||
setScanError(null);
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: "environment" },
|
||
});
|
||
if (videoRef.current) {
|
||
videoRef.current.srcObject = stream;
|
||
await videoRef.current.play();
|
||
}
|
||
const detector = new window.BarcodeDetector!({ formats: ["qr_code"] });
|
||
const scan = async () => {
|
||
if (!videoRef.current) return;
|
||
try {
|
||
const barcodes = await detector.detect(videoRef.current);
|
||
if (barcodes.length > 0) {
|
||
const raw = barcodes[0]!.rawValue;
|
||
// Parse /check-in?b=<bidderId>&e=<eventId>
|
||
try {
|
||
const url = new URL(raw);
|
||
const b = url.searchParams.get("b");
|
||
const e = url.searchParams.get("e");
|
||
if (b && e) {
|
||
stopScan(stream);
|
||
await doScanCheckIn(b, e);
|
||
return;
|
||
}
|
||
} catch {
|
||
// Not a valid URL — ignore
|
||
}
|
||
}
|
||
} catch {
|
||
// Detector error on a frame — continue
|
||
}
|
||
scanLoopRef.current = requestAnimationFrame(scan);
|
||
};
|
||
scanLoopRef.current = requestAnimationFrame(scan);
|
||
} catch {
|
||
setScanError("Camera access denied. Use manual search instead.");
|
||
setScanMode(false);
|
||
}
|
||
}, [doScanCheckIn]);
|
||
|
||
const stopScan = (stream?: MediaStream) => {
|
||
if (scanLoopRef.current) cancelAnimationFrame(scanLoopRef.current);
|
||
if (videoRef.current?.srcObject) {
|
||
(videoRef.current.srcObject as MediaStream).getTracks().forEach((t) => t.stop());
|
||
videoRef.current.srcObject = null;
|
||
}
|
||
if (stream) stream.getTracks().forEach((t) => t.stop());
|
||
setScanMode(false);
|
||
};
|
||
|
||
return (
|
||
<main className="min-h-screen bg-gray-50">
|
||
{/* Header */}
|
||
<div className="bg-brand-700 text-white px-4 py-4 flex items-center gap-3">
|
||
<span className="text-2xl">✅</span>
|
||
<div>
|
||
<h1 className="text-xl font-black">Check-In</h1>
|
||
<p className="text-xs opacity-70">Scan QR or search by name / paddle</p>
|
||
</div>
|
||
<div className="ml-auto">
|
||
{scanMode ? (
|
||
<button
|
||
onClick={() => stopScan()}
|
||
className="bg-white/20 text-white text-sm font-semibold px-3 py-1.5 rounded-lg"
|
||
>
|
||
Cancel Scan
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={startScan}
|
||
className="bg-white/20 text-white text-sm font-semibold px-3 py-1.5 rounded-lg"
|
||
>
|
||
Scan QR
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 space-y-4 max-w-xl mx-auto">
|
||
{/* Camera viewfinder */}
|
||
{scanMode && (
|
||
<div className="card overflow-hidden aspect-square relative">
|
||
<video ref={videoRef} className="w-full h-full object-cover" playsInline muted />
|
||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||
<div className="w-48 h-48 border-4 border-white/80 rounded-2xl" />
|
||
</div>
|
||
<p className="absolute bottom-3 left-0 right-0 text-center text-white text-xs font-semibold drop-shadow">
|
||
Point at bidder's QR code
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{scanError && (
|
||
<div className="bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700">
|
||
{scanError}
|
||
</div>
|
||
)}
|
||
|
||
{/* Manual search */}
|
||
{!scanMode && (
|
||
<input
|
||
type="search"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Search name, email, or paddle #…"
|
||
className="field"
|
||
autoFocus
|
||
/>
|
||
)}
|
||
|
||
{/* Search results */}
|
||
{results.length > 0 && (
|
||
<ul className="space-y-2">
|
||
{results.map((enrollment) => (
|
||
<li key={enrollment.id} className="card p-4 flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-700 font-black flex-shrink-0">
|
||
{enrollment.bidder.firstName.charAt(0).toUpperCase()}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-bold text-gray-900">
|
||
{enrollment.bidder.firstName} {enrollment.bidder.lastName}
|
||
</p>
|
||
<p className="text-xs text-gray-400">
|
||
{enrollment.bidder.email ?? enrollment.bidder.phone}
|
||
{enrollment.paddleNumber ? ` · Paddle #${enrollment.paddleNumber}` : ""}
|
||
</p>
|
||
</div>
|
||
{enrollment.checkInStatus === "checked_in" ? (
|
||
<span className="badge bg-emerald-100 text-emerald-700 shrink-0">
|
||
Checked In
|
||
</span>
|
||
) : (
|
||
<button
|
||
onClick={() => doCheckIn(enrollment.id)}
|
||
className="btn-primary text-sm px-4 py-2 shrink-0"
|
||
>
|
||
Check In
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{searching && (
|
||
<p className="text-center text-sm text-gray-400">Searching…</p>
|
||
)}
|
||
|
||
{/* Check-in result toast */}
|
||
{checkInResult && (
|
||
<div
|
||
className={`card p-5 border-2 ${
|
||
checkInResult.alreadyCheckedIn
|
||
? "border-amber-300 bg-amber-50"
|
||
: "border-emerald-400 bg-emerald-50"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-3xl">
|
||
{checkInResult.alreadyCheckedIn ? "⚠️" : "✅"}
|
||
</span>
|
||
<div>
|
||
<p className="font-bold text-gray-900">
|
||
{checkInResult.enrollment.bidder.firstName}{" "}
|
||
{checkInResult.enrollment.bidder.lastName}
|
||
</p>
|
||
{checkInResult.alreadyCheckedIn ? (
|
||
<p className="text-sm text-amber-700">Already checked in earlier</p>
|
||
) : (
|
||
<p className="text-sm text-emerald-700">
|
||
Checked in!
|
||
{checkInResult.enrollment.paddleNumber
|
||
? ` · Paddle #${checkInResult.enrollment.paddleNumber}`
|
||
: ""}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => setCheckInResult(null)}
|
||
className="ml-auto text-gray-400 hover:text-gray-600 text-xl leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty state */}
|
||
{!scanMode && query.trim().length >= 2 && results.length === 0 && !searching && (
|
||
<div className="card p-8 text-center text-gray-400 text-sm">
|
||
No bidders found for "{query}"
|
||
</div>
|
||
)}
|
||
|
||
{!scanMode && query.trim().length === 0 && !checkInResult && (
|
||
<div className="card p-6 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
|
||
Search above or tap <strong className="text-gray-500">Scan QR</strong> to use camera
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|