2026-05-02 19:46:42 -05:00
|
|
|
|
/**
|
2026-05-04 14:35:54 -05:00
|
|
|
|
* 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.
|
2026-05-02 19:46:42 -05:00
|
|
|
|
*/
|
2026-05-04 14:35:54 -05:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-02 19:46:42 -05:00
|
|
|
|
export default function CheckInPage() {
|
2026-05-04 14:35:54 -05:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-02 19:46:42 -05:00
|
|
|
|
return (
|
2026-05-04 14:35:54 -05:00
|
|
|
|
<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>
|
2026-05-02 20:14:15 -05:00
|
|
|
|
<div>
|
2026-05-04 14:35:54 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-05-02 20:14:15 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-04 14:35:54 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-05-02 20:14:15 -05:00
|
|
|
|
|
2026-05-04 14:35:54 -05:00
|
|
|
|
{!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>
|
|
|
|
|
|
)}
|
2026-05-02 19:46:42 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|