Phase 5
This commit is contained in:
@@ -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 & 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user