Files
hope-events/frontend/src/components/qr/QRScanner.tsx
T
joshuaandClaude Opus 5.5 545f3d913e Add shared API types and type the smaller frontend pages
Expand src/types with User, Event, EventOption, Registration, Payment,
Ticket, paginated results and dashboard stats, modelled on the Prisma
schema. apiFetch now defaults to unknown instead of any. Dashboard
loaders are wrapped in useCallback so their effects can list them as
dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00

207 lines
8.1 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import Webcam from "react-webcam";
import { useQRScanner } from "@/hooks/useQRScanner";
import { errorMessage } from "@/lib/utils";
export type QRScannerProps = {
onResult?: (text: string) => void;
onError?: (error: unknown) => void;
/** When true the camera stops; when it goes back to false the camera auto-resumes if it was on. */
paused?: boolean;
};
export type QRScannerHandle = {
/** Hard-stop the scanner (user must press "Scan Ticket" to restart). */
stop: () => void;
};
// Mobile-friendly QR scanner with start/stop control using the back camera via react-webcam
export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
function QRScanner({ onResult, onError, paused = false }, ref) {
const webcamRef = useRef<Webcam | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const [showCam, setShowCam] = useState<boolean>(false);
// Track the underlying HTMLVideoElement from react-webcam reliably
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
useEffect(() => {
let id: ReturnType<typeof setInterval> | undefined;
if (showCam) {
id = setInterval(() => {
const v = webcamRef.current?.video ?? null;
if (v) { setVideoEl(v); clearInterval(id); }
}, 100);
} else {
setVideoEl(null);
}
return () => { if (id) clearInterval(id); };
}, [showCam]);
// Beep + cooldown state
const lastScanRef = useRef<number>(0);
const audioCtxRef = useRef<AudioContext | null>(null);
const playBeep = () => {
try {
// Older Safari only has the prefixed constructor.
const AC = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
if (!audioCtxRef.current) audioCtxRef.current = new AC();
const ctx = audioCtxRef.current;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = "sine";
oscillator.frequency.value = 880;
gainNode.gain.value = 0.05;
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
const now = ctx.currentTime;
oscillator.start(now);
oscillator.stop(now + 0.12);
} catch {}
};
const wrappedOnResult = (text: string) => {
const nowTs = Date.now();
if (nowTs - lastScanRef.current < 2000) return;
lastScanRef.current = nowTs;
playBeep();
onResult?.(text);
};
const { active, toggle, stop, permissionError } = useQRScanner(videoEl, {
onResult: wrappedOnResult,
onError,
});
const constraints = useMemo(() => ({
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
}), []);
const isSecure = typeof window !== "undefined" && window.isSecureContext;
const isOn = showCam;
// Stable stop function so effects and the ref handle can depend on it safely
const stopScan = useCallback(() => {
try { stop(); } catch {}
setShowCam(false);
}, [stop]);
// Expose hard-stop to parent via ref (used when filters change)
useImperativeHandle(ref, () => ({ stop: stopScan }), [stopScan]);
// Pause / auto-resume when the paused prop changes (e.g. a modal opens/closes)
const wasActiveRef = useRef(false);
useEffect(() => {
if (paused) {
if (showCam) {
wasActiveRef.current = true;
stopScan();
}
} else {
if (wasActiveRef.current) {
wasActiveRef.current = false;
// Re-show the camera — active is still true so the decode loop restarts automatically
// once the video element is acquired again.
setShowCam(true);
}
}
// showCam intentionally included: re-evaluate after stopScan sets it to false
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paused, stopScan]);
async function startScan() {
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
// mediaDevices is missing on insecure origins and some older browsers.
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
setLocalError("Camera API is not available in this browser.");
return;
}
setLocalError(null);
try {
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false });
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
setShowCam(true);
if (!active) toggle();
} catch (e) {
// getUserMedia rejects with a DOMException whose `name` says why (NotAllowedError, ...).
const name = (e as { name?: string } | null)?.name || errorMessage(e);
if (name === "NotAllowedError") {
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
setLocalError("No suitable camera found. Try a device with a back camera.");
} else if (name === "NotReadableError") {
setLocalError("Camera is in use by another app. Close other apps and try again.");
} else if (!isSecure && !isLocal) {
setLocalError("Camera access requires HTTPS or running on http://localhost.");
} else {
setLocalError("Unable to access camera.");
}
if (active) stop();
setShowCam(false);
}
}
useEffect(() => {
return () => { stopScan(); };
}, [stopScan]);
return (
<div className="w-full max-w-md mx-auto">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">QR Scanner</h2>
<button
onClick={() => (isOn ? stopScan() : startScan())}
className={`px-4 py-2 rounded text-white ${isOn ? "bg-red-600" : "bg-brand-600"}`}
>
{isOn ? "Stop" : "Scan Ticket"}
</button>
</div>
<div className="relative rounded-lg overflow-hidden bg-black aspect-[3/4] sm:aspect-video">
{isOn ? (
<Webcam
ref={webcamRef}
audio={false}
videoConstraints={constraints}
mirrored={false}
forceScreenshotSourceSize
className="absolute inset-0 w-full h-full object-cover"
onUserMedia={() => setLocalError(null)}
onUserMediaError={(e) => {
const name = typeof e === "string" ? e : e.name || e.message;
const protocol = typeof window !== "undefined" ? window.location.protocol : "";
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
if (protocol !== "https:" && !isLocal) {
setLocalError("Camera access requires HTTPS or running on http://localhost.");
return;
}
if (name === "NotAllowedError") {
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
} else if (name === "NotFoundError") {
setLocalError("No suitable camera found. Try a device with a back camera.");
} else {
setLocalError("Unable to access camera.");
}
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-white/80 text-sm p-4 text-center">
Camera is off. Tap &quot;Scan Ticket&quot; to start using the back camera.
</div>
)}
</div>
{(permissionError || localError) && (
<p className="text-red-600 text-sm mt-2">{permissionError || localError}</p>
)}
</div>
);
}
);