Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
"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-blue-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>
);
}
);