Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user