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>
204 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
); |