feat: integrate barcode scanning functionality and enhance Appwrite setup
- 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.
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
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()];
|
||||
}
|
||||
Reference in New Issue
Block a user