import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, } from "react"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, flavourMeta } from "../data/flavours"; import { barcodeProductToEntryDraft, lookupBarcode, normalizeBarcode, productCaffeineMg, resolveProduct, } from "../lib/barcodeLookup"; import { scannerErrorMessage, startBarcodeScanner, stopVideoStream, type BarcodeScannerController, type BarcodeScannerError, type BarcodeScanResult, } from "../lib/barcodeScanner"; import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes"; import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics"; import { loadUserBarcodeMappings, upsertUserBarcodeMapping, } from "../lib/userBarcodeMappings"; import type { BarcodeLookupCatalog, BarcodeProductDraft, EntryDraft, Flavour, ResolvedBarcodeProduct, UserBarcodeMapping, } from "../types"; import { BarcodeProductPreview } from "./BarcodeProductPreview"; type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error"; export function BarcodeScannerModal({ busy, flavours, open, userId, onAddNow, onClose, onEditBeforeAdding, }: { busy: boolean; flavours: Flavour[]; open: boolean; userId: string; onAddNow: (draft: EntryDraft) => void; onClose: () => void; onEditBeforeAdding: (draft: EntryDraft) => void; }) { const videoRef = useRef(null); const closeButtonRef = useRef(null); const controllerRef = useRef(null); const barcodeCatalogRef = useRef({}); const lastScanRef = useRef<{ value: string; at: number } | null>(null); const [phase, setPhase] = useState("idle"); const [barcode, setBarcode] = useState(""); const [scannerMode, setScannerMode] = useState(null); const [scannerError, setScannerError] = useState(null); const [product, setProduct] = useState(null); const [typedBarcode, setTypedBarcode] = useState(""); const [manualMessage, setManualMessage] = useState(""); const [selectedFlavour, setSelectedFlavour] = useState(DEFAULT_FLAVOUR.name); const [sizePreset, setSizePreset] = useState("250"); const [customSize, setCustomSize] = useState("250"); const [pricePerCan, setPricePerCan] = useState(defaultPriceForSize(250).toFixed(2)); const [sugarFree, setSugarFree] = useState(Boolean(DEFAULT_FLAVOUR.sugarFree)); const [caffeineOverride, setCaffeineOverride] = useState(""); const [saveMapping, setSaveMapping] = useState(true); const [mappingSaving, setMappingSaving] = useState(false); const activeBarcode = barcode || normalizeBarcode(typedBarcode); const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset)); const manualProduct = useMemo( (): BarcodeProductDraft => ({ flavourName: selectedFlavour, sizeMl: numericSize, pricePerCan: Math.max(0, Number(pricePerCan) || 0), sugarFree: sugarFree || Boolean(flavourMeta(selectedFlavour).sugarFree), caffeineMgPerCan: caffeineOverride.trim() ? Math.max(0, Number(caffeineOverride) || 0) : undefined, }), [caffeineOverride, numericSize, pricePerCan, selectedFlavour, sugarFree], ); const manualCaffeine = productCaffeineMg(manualProduct); const stopScanner = useCallback(() => { controllerRef.current?.stop(); controllerRef.current = null; stopVideoStream(videoRef.current); }, []); const applyManualDefaults = useCallback((draft?: BarcodeProductDraft) => { const flavour = draft?.flavourName && BUILT_IN_FLAVOURS.some((item) => item.name === draft.flavourName) ? draft.flavourName : DEFAULT_FLAVOUR.name; const size = draft?.sizeMl ?? 250; const isStandardSize = size === 250 || size === 355 || size === 473; const meta = flavourMeta(flavour); setSelectedFlavour(flavour); setSizePreset(isStandardSize ? size.toString() : "custom"); setCustomSize(size.toString()); setPricePerCan((draft?.pricePerCan ?? defaultPriceForSize(size)).toFixed(2)); setSugarFree(draft?.sugarFree ?? Boolean(meta.sugarFree)); setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? ""); setSaveMapping(true); }, []); const resolveBarcodeValue = useCallback( (rawValue: string) => { const normalized = normalizeBarcode(rawValue); if (!normalized) { setScannerError({ code: "unsupported", message: scannerErrorMessage("unsupported") }); setPhase("error"); return; } const lookup = lookupBarcode(normalized, barcodeCatalogRef.current); setBarcode(normalized); setTypedBarcode(normalized); stopScanner(); if (lookup.status === "known" || lookup.status === "user") { setProduct(lookup.product); setManualMessage(""); setPhase("found"); return; } setProduct(null); applyManualDefaults(lookup.status === "partial" ? lookup.product : undefined); setManualMessage( lookup.status === "partial" ? lookup.reason : "Barcode found, but this product is not mapped yet. Add the drink details once and future scans can reuse them.", ); setPhase("manual"); }, [applyManualDefaults, stopScanner], ); const handleScannerResult = useCallback( (result: BarcodeScanResult) => { const normalized = normalizeBarcode(result.value); const lastScan = lastScanRef.current; const now = Date.now(); if (!normalized || (lastScan?.value === normalized && now - lastScan.at < 1_500)) return; lastScanRef.current = { value: normalized, at: now }; resolveBarcodeValue(normalized); }, [resolveBarcodeValue], ); const handleScannerError = useCallback( (error: BarcodeScannerError) => { stopScanner(); setScannerError(error); setPhase("error"); }, [stopScanner], ); useEffect(() => { if (!open) { stopScanner(); return undefined; } const localMappings = loadUserBarcodeMappings(userId); barcodeCatalogRef.current = { userMappings: localMappings }; lastScanRef.current = null; setPhase("starting"); setScannerError(null); setBarcode(""); setTypedBarcode(""); setProduct(null); setManualMessage(""); setMappingSaving(false); applyManualDefaults(); window.setTimeout(() => closeButtonRef.current?.focus(), 80); let active = true; const video = videoRef.current; if (!video) return undefined; void startBarcodeScanner(video, handleScannerResult, handleScannerError) .then((controller) => { if (!active) { controller.stop(); return; } controllerRef.current = controller; setScannerMode(controller.mode); setPhase("scanning"); }) .catch((error: BarcodeScannerError) => { if (!active) return; setScannerError(error); setPhase("error"); }); void listBarcodeCatalog() .then((catalog) => { if (!active) return; barcodeCatalogRef.current = { verifiedProducts: hasVerifiedProducts(catalog) ? catalog.verifiedProducts : undefined, userMappings: mergeUserMappings(localMappings, catalog.userMappings ?? []), }; }) .catch(() => { barcodeCatalogRef.current = { userMappings: localMappings }; }); return () => { active = false; stopScanner(); }; }, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]); useEffect(() => { if (!open) return undefined; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [onClose, open]); function submitTypedBarcode(event: FormEvent) { event.preventDefault(); resolveBarcodeValue(typedBarcode); } async function saveManualProduct(event: FormEvent) { event.preventDefault(); const normalized = normalizeBarcode(activeBarcode); if (!normalized) { setManualMessage("Enter the barcode number before saving a mapping."); return; } setMappingSaving(true); try { let mapping: UserBarcodeMapping | null = null; let savedMessage = ""; if (saveMapping) { try { mapping = await upsertCloudUserBarcodeMapping(userId, normalized, manualProduct); upsertUserBarcodeMapping(userId, normalized, manualProduct); savedMessage = "Saved to Appwrite and cached locally for future scans."; } catch { mapping = upsertUserBarcodeMapping(userId, normalized, manualProduct); savedMessage = "Saved locally for future scans on this device. Appwrite barcode sync is not available yet."; } barcodeCatalogRef.current = { ...barcodeCatalogRef.current, userMappings: mergeUserMappings( loadUserBarcodeMappings(userId), mapping ? [mapping] : [], ), }; } setBarcode(normalized); setTypedBarcode(normalized); setProduct(resolveProduct(manualProduct, mapping ? "user" : "built-in")); setManualMessage(savedMessage); setPhase("found"); } finally { setMappingSaving(false); } } function addProductNow(nextProduct: ResolvedBarcodeProduct) { onAddNow(barcodeProductToEntryDraft(nextProduct, activeBarcode)); } function editProductBeforeAdding(nextProduct: ResolvedBarcodeProduct) { onEditBeforeAdding(barcodeProductToEntryDraft(nextProduct, activeBarcode)); } const scannerStatus = phase === "starting" ? "Starting camera..." : phase === "scanning" ? `Scanning${scannerMode ? ` with ${scannerMode === "native" ? "native detector" : "ZXing fallback"}` : ""}...` : "Scanner paused"; return ( {open && (

Camera scan

Scan barcode

Point your camera at the barcode on the can.

{phase === "starting" || phase === "scanning" ? (

Searching for a retail barcode

Hold the can steady inside the frame. The camera will stop automatically after a match.

) : null} {phase === "error" && (
)} {phase === "manual" && (

Unknown barcode

{activeBarcode || "No barcode entered"}

{manualMessage}

{sizePreset === "custom" && ( <> )}
Estimated caffeine: {wholeNumber.format(manualCaffeine)}mg
Price: {currency.format(manualProduct.pricePerCan)}
)} {phase === "found" && product && ( addProductNow(product)} onCancel={onClose} onEdit={() => editProductBeforeAdding(product)} /> )}
)}
); } function hasVerifiedProducts(catalog: BarcodeLookupCatalog) { return Object.keys(catalog.verifiedProducts ?? {}).length > 0; } function mergeUserMappings( localMappings: UserBarcodeMapping[], cloudMappings: UserBarcodeMapping[], ) { const byBarcode = new Map(); localMappings.forEach((mapping) => byBarcode.set(mapping.barcode, mapping)); cloudMappings.forEach((mapping) => byBarcode.set(mapping.barcode, mapping)); return [...byBarcode.values()]; }