Files
hope-events/frontend/src/components/qr/QRScanner.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00

204 lines
7.7 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import Webcam from "react-webcam";
import { useQRScanner } from "@/hooks/useQRScanner";
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: any;
if (showCam) {
id = setInterval(() => {
const anyCam = webcamRef.current as any;
const v: HTMLVideoElement | null = anyCam?.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<any>(null);
const playBeep = () => {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
if (!AC) return;
if (!audioCtxRef.current) audioCtxRef.current = new AC();
const ctx = audioCtxRef.current as AudioContext;
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";
if (!(navigator as any)?.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 } as any);
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
setShowCam(true);
if (!active) toggle();
} catch (e: any) {
const name = e?.name || e?.message;
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 = (e as any)?.name || (e as any)?.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 "Scan Ticket" to start using the back camera.
</div>
)}
</div>
{(permissionError || localError) && (
<p className="text-red-600 text-sm mt-2">{permissionError || localError}</p>
)}
</div>
);
}
);