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:
Hank 2026-07-08 03:47:59 +00:00
commit 3397e3e3ec
60 changed files with 14703 additions and 0 deletions

64
app/lib/feedback.ts Normal file
View file

@ -0,0 +1,64 @@
import { Platform } from "react-native";
import { createAudioPlayer, setAudioModeAsync, type AudioPlayer } from "expo-audio";
import * as Haptics from "expo-haptics";
// Preloaded one-shot players. Created on first prime() call, which must happen
// in response to a user gesture (the PIN login tap) so web autoplay policies
// allow later programmatic playback.
let successPlayer: AudioPlayer | null = null;
let errorPlayer: AudioPlayer | null = null;
let primed = false;
export async function primeFeedback(): Promise<void> {
if (primed) return;
primed = true;
try {
await setAudioModeAsync({ playsInSilentMode: true });
} catch {
/* not fatal */
}
try {
successPlayer = createAudioPlayer(require("../assets/sounds/success.wav"));
errorPlayer = createAudioPlayer(require("../assets/sounds/error.wav"));
// Nudge the web audio context alive with a muted play/pause.
if (Platform.OS === "web") {
for (const p of [successPlayer, errorPlayer]) {
try {
p.volume = 0;
p.play();
p.pause();
p.seekTo(0);
p.volume = 1;
} catch {
/* ignore */
}
}
}
} catch {
/* audio unavailable; feedback falls back to haptics/visual only */
}
}
function replay(player: AudioPlayer | null): void {
if (!player) return;
try {
player.seekTo(0);
player.play();
} catch {
/* ignore */
}
}
export function feedbackSuccess(): void {
replay(successPlayer);
if (Platform.OS !== "web") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
}
}
export function feedbackError(): void {
replay(errorPlayer);
if (Platform.OS !== "web") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
}
}