CampgroundTickets/app/components/QRScanner.tsx
Hank 3397e3e3ec 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>
2026-07-08 03:47:59 +00:00

56 lines
1.9 KiB
TypeScript

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" },
});