ec9ea9d1f9
- Added a new `barcode_products` collection to the Appwrite setup for managing barcode data. - Implemented barcode scanning feature with a dedicated modal for scanning and adding products. - Introduced new components for barcode product preview and management. - Updated the setup script to seed verified barcode products from a JSON file. - Enhanced the application state management to handle barcode-related actions and user interactions.
540 lines
22 KiB
TypeScript
540 lines
22 KiB
TypeScript
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<HTMLVideoElement>(null);
|
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
const controllerRef = useRef<BarcodeScannerController | null>(null);
|
|
const barcodeCatalogRef = useRef<BarcodeLookupCatalog>({});
|
|
const lastScanRef = useRef<{ value: string; at: number } | null>(null);
|
|
const [phase, setPhase] = useState<ScannerPhase>("idle");
|
|
const [barcode, setBarcode] = useState("");
|
|
const [scannerMode, setScannerMode] = useState<BarcodeScannerController["mode"] | null>(null);
|
|
const [scannerError, setScannerError] = useState<BarcodeScannerError | null>(null);
|
|
const [product, setProduct] = useState<ResolvedBarcodeProduct | null>(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<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
resolveBarcodeValue(typedBarcode);
|
|
}
|
|
|
|
async function saveManualProduct(event: FormEvent<HTMLFormElement>) {
|
|
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 (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 backdrop-blur-xl sm:p-4"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="barcode-scanner-title"
|
|
>
|
|
<motion.div
|
|
className="modal-panel max-w-4xl"
|
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
|
transition={{ duration: 0.22 }}
|
|
>
|
|
<div className="mb-5 flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Camera scan</p>
|
|
<h2 id="barcode-scanner-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
|
Scan barcode
|
|
</h2>
|
|
<p className="mt-2 text-sm text-slate-300">Point your camera at the barcode on the can.</p>
|
|
</div>
|
|
<button ref={closeButtonRef} className="icon-button" type="button" onClick={onClose} aria-label="Close barcode scanner">
|
|
<X size={18} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
|
<section className="grid gap-3">
|
|
<div className="relative overflow-hidden rounded-3xl border border-cyan-200/20 bg-black shadow-2xl">
|
|
<video
|
|
ref={videoRef}
|
|
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
|
muted
|
|
playsInline
|
|
aria-label="Live camera preview"
|
|
/>
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
<div className="h-28 w-[78%] max-w-sm rounded-2xl border-2 border-cyan-200/90 shadow-[0_0_0_999px_rgba(0,0,0,0.28),0_0_32px_rgba(125,231,255,0.35)]" />
|
|
</div>
|
|
<div className="absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/60 px-3 py-2 text-sm text-white backdrop-blur">
|
|
<span className="inline-flex items-center gap-2">
|
|
{phase === "starting" ? (
|
|
<Loader2 className="animate-spin text-cyan-100" size={16} aria-hidden="true" />
|
|
) : (
|
|
<ScanLine className="text-cyan-100" size={16} aria-hidden="true" />
|
|
)}
|
|
{scannerStatus}
|
|
</span>
|
|
<span className="hidden text-xs text-slate-300 sm:inline">EAN/UPC</span>
|
|
</div>
|
|
</div>
|
|
|
|
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-3" onSubmit={submitTypedBarcode}>
|
|
<label className="field-label">
|
|
Type barcode instead
|
|
<span className="flex flex-col gap-2 sm:flex-row">
|
|
<input
|
|
className="field-control"
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
placeholder="EAN or UPC number"
|
|
value={typedBarcode}
|
|
onChange={(event) => setTypedBarcode(event.target.value)}
|
|
/>
|
|
<button className="secondary-button shrink-0 justify-center" type="submit">
|
|
<Keyboard size={17} aria-hidden="true" />
|
|
Lookup
|
|
</button>
|
|
</span>
|
|
</label>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="grid content-start gap-3">
|
|
{phase === "starting" || phase === "scanning" ? (
|
|
<div className="rounded-3xl border border-white/10 bg-white/[0.05] p-4">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-cyan-200/20 bg-cyan-200/10 text-cyan-100">
|
|
<Camera size={22} aria-hidden="true" />
|
|
</div>
|
|
<h3 className="mt-4 text-lg font-semibold text-white">Searching for a retail barcode</h3>
|
|
<p className="mt-2 text-sm leading-6 text-slate-300">
|
|
Hold the can steady inside the frame. The camera will stop automatically after a match.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{phase === "error" && (
|
|
<div className="rounded-3xl border border-amber-300/30 bg-amber-300/10 p-4 text-amber-50">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" />
|
|
<div>
|
|
<h3 className="font-semibold text-white">Scanner unavailable</h3>
|
|
<p className="mt-2 text-sm leading-6">{scannerError?.message ?? scannerErrorMessage("unknown")}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{phase === "manual" && (
|
|
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-4" onSubmit={saveManualProduct}>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-100">Unknown barcode</p>
|
|
<h3 className="mt-1 break-all text-xl font-semibold text-white">{activeBarcode || "No barcode entered"}</h3>
|
|
<p className="mt-2 text-sm leading-6 text-slate-300">{manualMessage}</p>
|
|
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
<label className="field-label">
|
|
Flavour
|
|
<select
|
|
className="field-control"
|
|
value={selectedFlavour}
|
|
onChange={(event) => {
|
|
const flavour = event.target.value;
|
|
setSelectedFlavour(flavour);
|
|
setSugarFree(Boolean(flavourMeta(flavour).sugarFree));
|
|
}}
|
|
>
|
|
{flavours.map((flavour) => (
|
|
<option key={flavour.name} value={flavour.name}>
|
|
{flavour.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="field-label">
|
|
Can size
|
|
<select
|
|
className="field-control"
|
|
value={sizePreset}
|
|
onChange={(event) => {
|
|
const next = event.target.value;
|
|
setSizePreset(next);
|
|
if (next !== "custom") {
|
|
const size = Number(next);
|
|
setCustomSize(next);
|
|
setPricePerCan(defaultPriceForSize(size).toFixed(2));
|
|
setCaffeineOverride("");
|
|
}
|
|
}}
|
|
>
|
|
<option value="250">250ml</option>
|
|
<option value="355">355ml</option>
|
|
<option value="473">473ml</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
</label>
|
|
|
|
{sizePreset === "custom" && (
|
|
<>
|
|
<label className="field-label">
|
|
Custom size in ml
|
|
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
|
</label>
|
|
<label className="field-label">
|
|
Caffeine mg/can
|
|
<input
|
|
className="field-control"
|
|
min="0"
|
|
step="1"
|
|
type="number"
|
|
value={caffeineOverride}
|
|
onChange={(event) => setCaffeineOverride(event.target.value)}
|
|
placeholder={wholeNumber.format(caffeinePerCan(numericSize))}
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
|
|
<label className="field-label">
|
|
Price
|
|
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
|
</label>
|
|
|
|
<div className="rounded-2xl border border-cyan-200/20 bg-cyan-200/10 px-3 py-3 text-sm text-cyan-50">
|
|
Estimated caffeine: {wholeNumber.format(manualCaffeine)}mg
|
|
<br />
|
|
Price: {currency.format(manualProduct.pricePerCan)}
|
|
</div>
|
|
|
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
|
<input className="h-4 w-4" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
|
Count this product as sugar-free / zero sugar
|
|
</label>
|
|
|
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
|
<input className="h-4 w-4" type="checkbox" checked={saveMapping} onChange={(event) => setSaveMapping(event.target.checked)} />
|
|
Save this barcode mapping locally for future scans
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
|
Cancel
|
|
</button>
|
|
<button className="primary-button justify-center" type="submit" disabled={mappingSaving}>
|
|
{mappingSaving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : null}
|
|
Save mapping preview
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{phase === "found" && product && (
|
|
<BarcodeProductPreview
|
|
barcode={activeBarcode}
|
|
busy={busy}
|
|
product={product}
|
|
onAddNow={() => addProductNow(product)}
|
|
onCancel={onClose}
|
|
onEdit={() => editProductBeforeAdding(product)}
|
|
/>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
function hasVerifiedProducts(catalog: BarcodeLookupCatalog) {
|
|
return Object.keys(catalog.verifiedProducts ?? {}).length > 0;
|
|
}
|
|
|
|
function mergeUserMappings(
|
|
localMappings: UserBarcodeMapping[],
|
|
cloudMappings: UserBarcodeMapping[],
|
|
) {
|
|
const byBarcode = new Map<string, UserBarcodeMapping>();
|
|
localMappings.forEach((mapping) => byBarcode.set(mapping.barcode, mapping));
|
|
cloudMappings.forEach((mapping) => byBarcode.set(mapping.barcode, mapping));
|
|
return [...byBarcode.values()];
|
|
}
|