Files
storybid/packages/client/src/pages/staff/CheckInPage.tsx
T
2026-05-04 14:35:54 -05:00

317 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}