Initial Camp Scan ticketing system
Backend (Fastify + TS): FluentForms webhook -> NocoDB row + QR + MailerSend email; PIN auth; scan/lookup/redeem with per-code serialization; reusable QR codes with count-based check-in; admin search. App (Expo, one codebase): Android APK + iPhone PWA. Login, camera scanner (native + web barcode-detector split), green/red overlay with sound + haptics, admin lookup/redeem. Session token persisted per device. Ops: multi-stage Dockerfile serving API + PWA same-origin, compose bound to 127.0.0.1; Forgejo Actions runner + tag-triggered signed APK build for Obtainium. Docs in README.md and INSTALL.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
3397e3e3ec
60 changed files with 14703 additions and 0 deletions
56
app/components/QRScanner.tsx
Normal file
56
app/components/QRScanner.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useRef } from "react";
|
||||
import { StyleSheet, View, Text, Pressable } from "react-native";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import { theme } from "../lib/theme";
|
||||
|
||||
export interface QRScannerProps {
|
||||
onScan: (code: string) => void;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Native (Android/iOS) scanner using expo-camera. */
|
||||
export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
|
||||
|
||||
if (!permission) {
|
||||
return <View style={styles.fill} />;
|
||||
}
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={[styles.fill, styles.center]}>
|
||||
<Text style={styles.msg}>Camera access is needed to scan tickets.</Text>
|
||||
<Pressable style={styles.btn} onPress={requestPermission}>
|
||||
<Text style={styles.btnText}>Grant camera permission</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CameraView
|
||||
style={styles.fill}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
||||
onBarcodeScanned={
|
||||
active
|
||||
? ({ data }) => {
|
||||
const now = Date.now();
|
||||
// Debounce repeated frames of the same code.
|
||||
if (data === lastScan.current.code && now - lastScan.current.at < 3000) return;
|
||||
lastScan.current = { code: data, at: now };
|
||||
onScan(data);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fill: { flex: 1, width: "100%", height: "100%" },
|
||||
center: { alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: theme.bg },
|
||||
msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 20 },
|
||||
btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 },
|
||||
btnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
});
|
||||
121
app/components/QRScanner.web.tsx
Normal file
121
app/components/QRScanner.web.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { StyleSheet, View, Text, Pressable } from "react-native";
|
||||
import { BarcodeDetector } from "barcode-detector/ponyfill";
|
||||
import { theme } from "../lib/theme";
|
||||
import type { QRScannerProps } from "./QRScanner";
|
||||
|
||||
/** Web/PWA scanner using getUserMedia + the BarcodeDetector ponyfill (zxing-wasm). */
|
||||
export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const activeRef = useRef(active);
|
||||
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(true);
|
||||
|
||||
activeRef.current = active;
|
||||
|
||||
async function start() {
|
||||
setError(null);
|
||||
setStarting(true);
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: "environment" } },
|
||||
audio: false,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.srcObject = stream;
|
||||
video.setAttribute("playsinline", "true");
|
||||
await video.play().catch(() => {});
|
||||
}
|
||||
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
||||
let busy = false;
|
||||
const tick = async () => {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
const v = videoRef.current;
|
||||
if (!v || v.readyState < 2 || busy || !activeRef.current) return;
|
||||
busy = true;
|
||||
try {
|
||||
const codes = await detector.detect(v);
|
||||
if (codes && codes.length) {
|
||||
const data = codes[0].rawValue;
|
||||
const now = Date.now();
|
||||
if (!(data === lastScan.current.code && now - lastScan.current.at < 3000)) {
|
||||
lastScan.current = { code: data, at: now };
|
||||
onScan(data);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* transient decode error; keep scanning */
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
setStarting(false);
|
||||
} catch (e: any) {
|
||||
setStarting(false);
|
||||
setError(
|
||||
e?.name === "NotAllowedError"
|
||||
? "Camera permission was denied. Allow camera access and reload."
|
||||
: "Could not open the camera. Make sure you're on HTTPS and no other app is using it.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
start();
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.fill}>
|
||||
{/* Raw DOM video element; react-dom renders it inside the RN-Web div tree. */}
|
||||
<video
|
||||
ref={videoRef as any}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
{starting && !error && (
|
||||
<View style={styles.overlayMsg}>
|
||||
<Text style={styles.msg}>Starting camera…</Text>
|
||||
</View>
|
||||
)}
|
||||
{error && (
|
||||
<View style={styles.overlayMsg}>
|
||||
<Text style={styles.msg}>{error}</Text>
|
||||
<Pressable style={styles.btn} onPress={start}>
|
||||
<Text style={styles.btnText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fill: { flex: 1, width: "100%", height: "100%", backgroundColor: "#000" },
|
||||
overlayMsg: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
backgroundColor: theme.bg,
|
||||
},
|
||||
msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 16 },
|
||||
btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 },
|
||||
btnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
});
|
||||
32
app/components/ResultOverlay.tsx
Normal file
32
app/components/ResultOverlay.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { ReactNode } from "react";
|
||||
import { StyleSheet, View, Pressable } from "react-native";
|
||||
import { theme } from "../lib/theme";
|
||||
|
||||
export type OverlayStatus = "success" | "error" | "neutral";
|
||||
|
||||
const BG: Record<OverlayStatus, string> = {
|
||||
success: theme.success,
|
||||
error: theme.danger,
|
||||
neutral: theme.card,
|
||||
};
|
||||
|
||||
export default function ResultOverlay({
|
||||
status,
|
||||
onDismiss,
|
||||
children,
|
||||
}: {
|
||||
status: OverlayStatus;
|
||||
onDismiss?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Pressable style={[styles.fill, { backgroundColor: BG[status] }]} onPress={onDismiss}>
|
||||
<View style={styles.inner}>{children}</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fill: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 10 },
|
||||
inner: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue