diff --git a/apps/web/src/components/drinkkaart/QrScanner.tsx b/apps/web/src/components/drinkkaart/QrScanner.tsx index c8bcfc8..fd13289 100644 --- a/apps/web/src/components/drinkkaart/QrScanner.tsx +++ b/apps/web/src/components/drinkkaart/QrScanner.tsx @@ -5,14 +5,16 @@ import { Input } from "@/components/ui/input"; interface QrScannerProps { onScan: (token: string) => void; onCancel: () => void; + /** When true the scanner takes up more vertical space (mobile full-screen mode) */ + fullHeight?: boolean; } -export function QrScanner({ onScan, onCancel }: QrScannerProps) { - const scannerDivId = "qr-reader-container"; +export function QrScanner({ onScan, onCancel, fullHeight }: QrScannerProps) { + const videoContainerId = "qr-video-container"; const scannerRef = useRef(null); const [hasError, setHasError] = useState(false); const [manualToken, setManualToken] = useState(""); - // Keep a stable ref to onScan so the effect never re-runs due to identity changes + // Keep a stable ref so the effect never re-runs due to callback identity changes const onScanRef = useRef(onScan); useEffect(() => { onScanRef.current = onScan; @@ -22,32 +24,34 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) { let stopped = false; import("html5-qrcode") - .then(({ Html5QrcodeScanner }) => { + .then(({ Html5Qrcode }) => { if (stopped) return; - const scanner = new Html5QrcodeScanner( - scannerDivId, - { - fps: 10, - qrbox: { width: 250, height: 250 }, - }, - false, - ); - + const scanner = new Html5Qrcode(videoContainerId); scannerRef.current = scanner; - scanner.render( - (decodedText: string) => { - scanner.clear().catch(console.error); - onScanRef.current(decodedText); - }, - (error: string) => { - // Suppress routine "not found" scan errors - if (!error.includes("No QR code found")) { - console.debug("QR scan error:", error); + scanner + .start( + { facingMode: "environment" }, + { fps: 15, qrbox: { width: 240, height: 240 } }, + (decodedText: string) => { + // Stop immediately so we don't fire multiple times + scanner + .stop() + .catch(console.error) + .finally(() => { + if (!stopped) onScanRef.current(decodedText); + }); + }, + // Per-frame error (QR not found) — suppress + () => {}, + ) + .catch((err: unknown) => { + if (!stopped) { + console.error("Camera start failed:", err); + setHasError(true); } - }, - ); + }); }) .catch((err) => { console.error("Failed to load html5-qrcode:", err); @@ -57,27 +61,40 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) { return () => { stopped = true; if (scannerRef.current) { - (scannerRef.current as { clear: () => Promise }) - .clear() - .catch(console.error); + ( + scannerRef.current as { + stop: () => Promise; + clear: () => Promise; + } + ) + .stop() + .catch(() => {}) + .finally(() => { + (scannerRef.current as { clear: () => Promise }) + ?.clear?.() + .catch(() => {}); + }); } }; - }, []); // empty deps — runs once on mount, cleans up on unmount + }, []); // runs once on mount const handleManualSubmit = () => { const token = manualToken.trim(); - if (token) { - onScan(token); - } + if (token) onScan(token); }; + const containerHeight = fullHeight ? "h-64 sm:h-80" : "h-52"; + return (
{!hasError ? ( -
+
video]:h-full [&>video]:w-full [&>video]:object-cover`} + /> ) : ( -
-

+

+

Camera niet beschikbaar. Plak het QR-token hieronder.

diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c2f14e9..8e5df4c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -128,7 +128,7 @@ export const Route = createRootRouteWithContext()({ name: "Kunstenkamp Open Mic Night", description: "Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom om zijn of haar talent te tonen.", - startDate: "2026-04-18T19:00:00+02:00", + startDate: "2026-04-24T19:30:00+02:00", eventStatus: "https://schema.org/EventScheduled", eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode", organizer: { diff --git a/apps/web/src/routes/contact.tsx b/apps/web/src/routes/contact.tsx index f729730..2a71a96 100644 --- a/apps/web/src/routes/contact.tsx +++ b/apps/web/src/routes/contact.tsx @@ -39,7 +39,7 @@ function ContactPage() {

Open Mic Night

Vrijdag 24 april 2026

-

Aanvang: 19:00 uur

+

Aanvang: 19:30 uur

Lange Winkelstraat 5, 2000 Antwerpen

diff --git a/apps/web/src/routes/drinkkaart.tsx b/apps/web/src/routes/drinkkaart.tsx index 3ee6452..fcc0adf 100644 --- a/apps/web/src/routes/drinkkaart.tsx +++ b/apps/web/src/routes/drinkkaart.tsx @@ -1,21 +1,575 @@ +import { useMutation } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { DEDUCTION_PRESETS_CENTS, formatCents } from "@/lib/drinkkaart"; +import { orpc } from "@/utils/orpc"; -// /drinkkaart redirects to /account, preserving ?topup=success for -// backward-compatibility with the Lemon Squeezy webhook return URL. export const Route = createFileRoute("/drinkkaart")({ - validateSearch: (search: Record) => ({ - topup: search.topup as string | undefined, - }), - beforeLoad: async ({ search }) => { + beforeLoad: async () => { const session = await authClient.getSession(); if (!session.data?.user) { throw redirect({ to: "/login" }); } - throw redirect({ - to: "/account", - search: search.topup ? { topup: search.topup } : {}, - }); + const user = session.data.user as { role?: string }; + if (user.role !== "admin") { + throw redirect({ to: "/" }); + } }, - component: () => null, + component: DrinkkaartScanPage, }); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ScanState = + | { step: "scanning" } + | { step: "resolving" } + | { + step: "resolved"; + drinkkaartId: string; + userId: string; + userName: string; + userEmail: string; + balance: number; + } + | { + step: "confirming"; + drinkkaartId: string; + userName: string; + userEmail: string; + balance: number; + amountCents: number; + } + | { + step: "success"; + userName: string; + amountCents: number; + balanceAfter: number; + } + | { step: "error"; message: string }; + +// --------------------------------------------------------------------------- +// ScanningView — full-screen camera with manual fallback +// --------------------------------------------------------------------------- + +function ScanningView({ onScan }: { onScan: (token: string) => void }) { + const videoContainerId = "qr-video-container"; + const scannerRef = useRef(null); + const [hasError, setHasError] = useState(false); + const [manualToken, setManualToken] = useState(""); + const [showManual, setShowManual] = useState(false); + const onScanRef = useRef(onScan); + useEffect(() => { + onScanRef.current = onScan; + }, [onScan]); + + useEffect(() => { + if (hasError) return; + let stopped = false; + + import("html5-qrcode") + .then(({ Html5Qrcode }) => { + if (stopped) return; + const scanner = new Html5Qrcode(videoContainerId); + scannerRef.current = scanner; + scanner + .start( + { facingMode: "environment" }, + { fps: 15, qrbox: { width: 240, height: 240 } }, + (decodedText: string) => { + scanner + .stop() + .catch(console.error) + .finally(() => { + if (!stopped) onScanRef.current(decodedText); + }); + }, + () => {}, + ) + .catch((err: unknown) => { + if (!stopped) { + console.error("Camera start failed:", err); + setHasError(true); + setShowManual(true); + } + }); + }) + .catch((err) => { + console.error("Failed to load html5-qrcode:", err); + setHasError(true); + setShowManual(true); + }); + + return () => { + stopped = true; + const s = scannerRef.current as { + stop: () => Promise; + clear: () => Promise; + } | null; + s?.stop() + .catch(() => {}) + .finally(() => s?.clear().catch(() => {})); + }; + }, [hasError]); + + const handleManualSubmit = () => { + const token = manualToken.trim(); + if (token) onScan(token); + }; + + return ( +
+ {/* Camera — fills all available space above the hint */} + {!hasError && ( +
+ )} + + {hasError && ( +
+
+

Camera niet beschikbaar.

+
+
+ )} + + {/* Bottom controls */} +
+ {showManual ? ( +
+ setManualToken(e.target.value)} + placeholder="Plak QR token..." + className="border-white/20 bg-white/10 text-white placeholder:text-white/30" + onKeyDown={(e) => e.key === "Enter" && handleManualSubmit()} + /> + +
+ ) : ( +
+

+ Richt de camera op de QR-code +

+ +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +function DrinkkaartScanPage() { + const [scanState, setScanState] = useState({ step: "scanning" }); + const [customCents, setCustomCents] = useState(""); + const [useCustom, setUseCustom] = useState(false); + const [selectedCents, setSelectedCents] = useState( + DEDUCTION_PRESETS_CENTS[1], + ); + const successTimerRef = useRef | null>(null); + + // Auto-return to scanner 1.5s after success + useEffect(() => { + if (scanState.step === "success") { + successTimerRef.current = setTimeout(() => { + setScanState({ step: "scanning" }); + setCustomCents(""); + setUseCustom(false); + setSelectedCents(DEDUCTION_PRESETS_CENTS[1]); + }, 1500); + } + return () => { + if (successTimerRef.current) clearTimeout(successTimerRef.current); + }; + }, [scanState.step]); + + // --- Mutations --- + const resolveQrMutation = useMutation( + orpc.drinkkaart.resolveQrToken.mutationOptions(), + ); + const deductMutation = useMutation( + orpc.drinkkaart.deductBalance.mutationOptions(), + ); + + // --- Handlers --- + const handleScan = useCallback( + (token: string) => { + setScanState({ step: "resolving" }); + resolveQrMutation.mutate( + { token }, + { + onSuccess: (data) => { + setScanState({ + step: "resolved", + drinkkaartId: data.drinkkaartId, + userId: data.userId, + userName: data.userName, + userEmail: data.userEmail, + balance: data.balance, + }); + }, + onError: (err: Error) => { + setScanState({ + step: "error", + message: err.message ?? "Ongeldig QR-token", + }); + }, + }, + ); + }, + [resolveQrMutation], + ); + + const handlePresetSelect = (cents: number) => { + setSelectedCents(cents); + setUseCustom(false); + }; + + const getAmountCents = () => { + if (useCustom) { + return Math.round((Number.parseFloat(customCents || "0") || 0) * 100); + } + return selectedCents; + }; + + const handleDeductTap = () => { + if (scanState.step !== "resolved") return; + const amountCents = getAmountCents(); + if (!amountCents || amountCents < 1 || amountCents > scanState.balance) + return; + setScanState({ + step: "confirming", + drinkkaartId: scanState.drinkkaartId, + userName: scanState.userName, + userEmail: scanState.userEmail, + balance: scanState.balance, + amountCents, + }); + }; + + const handleConfirm = () => { + if (scanState.step !== "confirming") return; + deductMutation.mutate( + { + drinkkaartId: scanState.drinkkaartId, + amountCents: scanState.amountCents, + }, + { + onSuccess: (data) => { + toast.success(`${formatCents(scanState.amountCents)} afgeschreven`); + setScanState({ + step: "success", + userName: scanState.userName, + amountCents: scanState.amountCents, + balanceAfter: data.balanceAfter, + }); + deductMutation.reset(); + }, + onError: (err: Error) => { + setScanState({ + step: "error", + message: err.message ?? "Fout bij afschrijving", + }); + deductMutation.reset(); + }, + }, + ); + }; + + const restartScanner = () => { + setScanState({ step: "scanning" }); + setCustomCents(""); + setUseCustom(false); + setSelectedCents(DEDUCTION_PRESETS_CENTS[1]); + deductMutation.reset(); + }; + + // Determine the display amount for the deduct button (only in resolved step) + const resolvedAmountCents = + scanState.step === "resolved" ? getAmountCents() : 0; + const resolvedBalance = scanState.step === "resolved" ? scanState.balance : 0; + const deductIsValid = + scanState.step === "resolved" && + Number.isInteger(resolvedAmountCents) && + resolvedAmountCents >= 1 && + resolvedAmountCents <= resolvedBalance; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + return ( +
+ {/* ------------------------------------------------------------------ */} + {/* Header */} + {/* ------------------------------------------------------------------ */} +
+

+ Drinkkaart +

+ {/* Only show the admin link when not mid-flow */} + {(scanState.step === "scanning" || scanState.step === "error") && ( + + Beheer → + + )} + {scanState.step === "resolved" && ( + + )} + {scanState.step === "confirming" && ( + + )} +
+ + {/* ------------------------------------------------------------------ */} + {/* Main content area */} + {/* ------------------------------------------------------------------ */} +
+ {/* ---- SCANNING ---- */} + {scanState.step === "scanning" && } + + {/* ---- RESOLVING ---- */} + {scanState.step === "resolving" && ( +
+
+

Controleren...

+
+ )} + + {/* ---- RESOLVED: show person + deduct controls ---- */} + {scanState.step === "resolved" && ( +
+ {/* Person card */} +
+
+
+

+ {scanState.userName} +

+

+ {scanState.userEmail} +

+
+
+

Saldo

+

+ {formatCents(scanState.balance)} +

+
+
+
+ + {/* Amount selection */} +
+

Bedrag kiezen

+ + {/* Preset grid */} +
+ {DEDUCTION_PRESETS_CENTS.map((cents) => ( + + ))} +
+ + {/* Custom amount */} +
+ + € + + { + setCustomCents(e.target.value); + setUseCustom(true); + }} + onFocus={() => setUseCustom(true)} + className="border-white/20 bg-white/10 py-4 pl-8 text-base text-white placeholder:text-white/30" + /> +
+ + {/* Insufficient balance warning */} + {resolvedAmountCents > resolvedBalance && ( +

+ Onvoldoende saldo ({formatCents(resolvedBalance)}) +

+ )} +
+ + {/* Deduct button — sticky at bottom */} +
+ +
+
+ )} + + {/* ---- CONFIRMING ---- */} + {scanState.step === "confirming" && ( +
+
+ {/* Summary card */} +
+

Afschrijving voor

+

+ {scanState.userName} +

+

+ {formatCents(scanState.amountCents)} +

+

+ Nieuw saldo:{" "} + + {formatCents(scanState.balance - scanState.amountCents)} + +

+
+ + {deductMutation.isError && ( +

+ {(deductMutation.error as Error)?.message ?? + "Fout bij afschrijving"} +

+ )} + + {/* Confirm button */} + +
+
+ )} + + {/* ---- SUCCESS ---- */} + {scanState.step === "success" && ( +
+
+
+ + + +
+
+

+ {formatCents(scanState.amountCents)} afgeschreven +

+

+ {scanState.userName} — nieuw saldo:{" "} + + {formatCents(scanState.balanceAfter)} + +

+
+

+ Volgende scan wordt gestart… +

+
+
+ )} + + {/* ---- ERROR ---- */} + {scanState.step === "error" && ( +
+
+
+

Fout

+

+ {scanState.message} +

+
+ +
+
+ )} +
+
+ ); +} diff --git a/packages/db/local.db b/packages/db/local.db new file mode 100644 index 0000000..0a06b00 Binary files /dev/null and b/packages/db/local.db differ diff --git a/packages/db/local.db-shm b/packages/db/local.db-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/packages/db/local.db-shm differ diff --git a/packages/db/local.db-wal b/packages/db/local.db-wal new file mode 100644 index 0000000..e69de29