This commit is contained in:
2026-05-04 14:35:54 -05:00
parent 884043cf22
commit 11d227cedb
16 changed files with 1830 additions and 56 deletions
+303 -20
View File
@@ -1,32 +1,315 @@
/**
* Check-in station — search bidders, scan QR, assign paddle, confirm payment readiness.
*
* TODO:
* - Search /api/bidders?eventId=&q=
* - QR scanner via device camera
* - POST /api/check-in/:id on confirm
* - Show payment-on-file indicator
* 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 p-4 space-y-4">
<div className="flex items-center gap-3">
<span className="text-3xl"></span>
<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-2xl font-black text-gray-900">Check-In</h1>
<p className="text-sm text-gray-400">Scan QR or search bidder name / paddle</p>
<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>
<input
type="search"
placeholder="Search paddle # or bidder name…"
className="field"
disabled
/>
<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>
)}
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
QR scan &amp; bidder search not yet implemented
{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>
);