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:
Ned Halksworth
2026-05-27 14:29:22 +01:00
parent 38deca4562
commit ec9ea9d1f9
19 changed files with 2033 additions and 164 deletions
+94 -52
View File
@@ -3,6 +3,7 @@ import {
Activity,
AlertTriangle,
CalendarDays,
Camera,
ChevronRight,
Cloud,
Command,
@@ -82,6 +83,7 @@ import {
updateEntry,
} from "./lib/appwriteEntries";
import { CoachPanel } from "./components/CoachPanel";
import { BarcodeScannerModal } from "./components/BarcodeScannerModal";
import { DailyLimitsCard } from "./components/DailyLimitsCard";
import { LimitsSettingsForm } from "./components/LimitsSettingsForm";
import { OnboardingScreen } from "./components/OnboardingScreen";
@@ -191,7 +193,9 @@ function App() {
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const [activeView, setActiveView] = useState<AppView>("overview");
const [isEntryModalOpen, setIsEntryModalOpen] = useState(false);
const [entryInitialDraft, setEntryInitialDraft] = useState<EntryDraft | null>(null);
const [editingEntry, setEditingEntry] = useState<RedBullEntry | null>(null);
const [isBarcodeScannerOpen, setIsBarcodeScannerOpen] = useState(false);
const [isResetOpen, setIsResetOpen] = useState(false);
const [notice, setNotice] = useState("Appwrite session pending.");
const [dataLoading, setDataLoading] = useState(false);
@@ -390,9 +394,14 @@ function App() {
function openNewEntry() {
setEditingEntry(null);
setEntryInitialDraft(null);
setIsEntryModalOpen(true);
}
function openBarcodeScanner() {
setIsBarcodeScannerOpen(true);
}
async function saveUserLimits(next: UserLimits) {
if (!user) return;
setActionLoading("save-limits");
@@ -451,6 +460,7 @@ function App() {
);
setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
setEditingEntry(null);
setEntryInitialDraft(null);
setIsEntryModalOpen(false);
} catch (error) {
setDataError(appwriteErrorMessage(error));
@@ -478,6 +488,18 @@ function App() {
requestEntrySave(draft, editingEntry?.id);
}
function addBarcodeDraft(draft: EntryDraft) {
setIsBarcodeScannerOpen(false);
requestEntrySave(draft);
}
function editBarcodeDraft(draft: EntryDraft) {
setIsBarcodeScannerOpen(false);
setEditingEntry(null);
setEntryInitialDraft(draft);
setIsEntryModalOpen(true);
}
async function quickAdd(item: (typeof QUICK_ADDS)[number]) {
if (!user) return;
const meta = flavourMeta(item.flavour);
@@ -682,6 +704,7 @@ function App() {
setupStatus={setupStatus}
user={user}
onAdd={openNewEntry}
onScan={openBarcodeScanner}
onChange={setActiveView}
onOpenSettings={() => setActiveView("settings")}
/>
@@ -693,6 +716,7 @@ function App() {
activeView={activeView}
actionLoading={actionLoading}
onAdd={openNewEntry}
onScan={openBarcodeScanner}
/>
<StatusRail actionLoading={actionLoading} dataError={dataError} setupStatus={setupStatus} />
@@ -721,6 +745,7 @@ function App() {
coachSession={coachSession}
onQuickAdd={(item) => void quickAdd(item)}
onAdd={openNewEntry}
onScan={openBarcodeScanner}
onOpenCoach={(prompt) => {
if (prompt) coachSession.queuePrompt(prompt);
setActiveView("coach");
@@ -801,6 +826,7 @@ function App() {
<EntryModal
entry={editingEntry}
initialDraft={entryInitialDraft}
flavours={allFlavours}
open={isEntryModalOpen}
saving={actionLoading === "save-entry"}
@@ -809,10 +835,21 @@ function App() {
onClose={() => {
setIsEntryModalOpen(false);
setEditingEntry(null);
setEntryInitialDraft(null);
}}
onSave={(draft) => void saveEntry(draft)}
/>
<BarcodeScannerModal
busy={actionLoading === "save-entry"}
flavours={allFlavours}
open={isBarcodeScannerOpen}
userId={user.$id}
onAddNow={addBarcodeDraft}
onClose={() => setIsBarcodeScannerOpen(false)}
onEditBeforeAdding={editBarcodeDraft}
/>
<ImportPreviewModal
busy={actionLoading === "confirm-excel-import"}
preview={importPreview}
@@ -1000,30 +1037,6 @@ function AuthView({
);
}
function AuthSignal({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
<div className="rounded-lg border border-white/10 bg-white/[0.06] p-3">
<Icon className="mb-3 text-cyan-200" size={18} aria-hidden="true" />
<p className="text-xs font-medium uppercase tracking-[0.16em] text-slate-400">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-white">{value}</p>
</div>
);
}
function CurrentThemeIndicator({
theme,
onClick,
}: {
theme: AppTheme;
onClick: () => void;
}) {
return (
<button className="theme-indicator" type="button" onClick={onClick} aria-label={`Theme: ${theme.label}. Open settings.`}>
<span className="theme-indicator-swatch" style={{ background: theme.swatch }} aria-hidden="true" />
<span className="theme-indicator-label">{theme.label}</span>
</button>
);
}
function ThemePicker({
themeId,
@@ -1091,6 +1104,7 @@ function Sidebar({
setupStatus,
user,
onAdd,
onScan,
onChange,
onOpenSettings,
}: {
@@ -1100,6 +1114,7 @@ function Sidebar({
setupStatus: SetupStatus;
user: AuthUser;
onAdd: () => void;
onScan: () => void;
onChange: (view: AppView) => void;
onOpenSettings: () => void;
}) {
@@ -1120,6 +1135,11 @@ function Sidebar({
Add intake
</button>
<button className="secondary-button mb-5 w-full justify-center" type="button" onClick={onScan}>
<Camera size={17} aria-hidden="true" />
Scan barcode
</button>
<nav className="drawer-nav" aria-label="Main navigation">
{NAV_ITEMS.map((item) => (
<button
@@ -1175,10 +1195,12 @@ function TopBar({
activeView,
actionLoading,
onAdd,
onScan,
}: {
activeView: AppView;
actionLoading: string | null;
onAdd: () => void;
onScan: () => void;
}) {
const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0];
const title = activeItem.label;
@@ -1204,6 +1226,10 @@ function TopBar({
</div>
<div className="top-action-row">
<button className="secondary-button justify-center min-h-12 text-sm active:scale-95" type="button" onClick={onScan} disabled={Boolean(actionLoading)}>
<Camera size={18} aria-hidden="true" />
Scan barcode
</button>
<button className="primary-button justify-center min-h-12 text-sm active:scale-95" type="button" onClick={onAdd} disabled={Boolean(actionLoading)}>
<Plus size={18} aria-hidden="true" />
Add Intake
@@ -1261,6 +1287,7 @@ function OverviewView({
limitCheck,
onQuickAdd,
onAdd,
onScan,
onOpenCoach,
onOpenLogbook,
onOpenSettings,
@@ -1278,6 +1305,7 @@ function OverviewView({
coachSession: CoachSession;
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
onAdd: () => void;
onScan: () => void;
onOpenCoach: (prompt?: string) => void;
onOpenLogbook: () => void;
onOpenSettings: () => void;
@@ -1305,7 +1333,7 @@ function OverviewView({
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
</section>
<TodayPanel dashboard={dashboard} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} />
<TodayPanel dashboard={dashboard} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
{limitCheck.violations.length ? (
<section className="glass-panel border border-amber-200/20 bg-amber-200/10 p-4 sm:p-5">
@@ -1510,12 +1538,14 @@ function TodayPanel({
userLimits,
limitCheck,
onAdd,
onScan,
}: {
dashboard: Dashboard;
entries: RedBullEntry[];
userLimits: UserLimits;
limitCheck: LimitCheckResult;
onAdd: () => void;
onScan: () => void;
}) {
const limitSummary = [
userLimits.dailyCanLimit != null ? `${limitCheck.todayCans.toFixed(1)}/${userLimits.dailyCanLimit} cans` : null,
@@ -1542,6 +1572,10 @@ function TodayPanel({
</div>
</div>
<div className="today-action-row mt-6 hidden flex-wrap items-center gap-2 lg:flex">
<button className="secondary-button" type="button" onClick={onScan}>
<Camera size={18} aria-hidden="true" />
Scan barcode
</button>
<button className="primary-button" type="button" onClick={onAdd}>
<Plus size={18} aria-hidden="true" />
Add intake
@@ -1761,11 +1795,13 @@ function SpendingPredictionsCard({
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
)[0].dateTime
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries]);
const trackingDays = useMemo(() => {
const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime());
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [firstEntryDate]);
const activePeriodDays = Math.min(30, trackingDays);
@@ -1781,12 +1817,13 @@ function SpendingPredictionsCard({
avgDailyCans: totalCans / activePeriodDays,
hasData: entries.length > 0,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, activePeriodDays]);
const projectionData = useMemo(() => {
return Array.from({ length: projectionDays }).map((_, index) => {
const day = index + 1;
const dataPoint: any = {
const dataPoint: Record<string, string | number> = {
label: `Day ${day}`,
"Current Path": Number((day * stats.avgDailySpend).toFixed(2)),
"Optimal Path (-20%)": Number((day * stats.avgDailySpend * 0.8).toFixed(2)),
@@ -2032,7 +2069,7 @@ function SettingsView({
</div>
<div className="mt-5 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<button className="secondary-button justify-center" type="button" onClick={() => { typeof window !== 'undefined' && window.location.reload(); }} disabled={dataLoading}>
<button className="secondary-button justify-center" type="button" onClick={() => { if (typeof window !== 'undefined') window.location.reload(); }} disabled={dataLoading}>
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
Sync now
</button>
@@ -2415,6 +2452,7 @@ function DisclaimerCard() {
function EntryModal({
open,
entry,
initialDraft,
flavours,
saving,
userLimits,
@@ -2424,6 +2462,7 @@ function EntryModal({
}: {
open: boolean;
entry: RedBullEntry | null;
initialDraft: EntryDraft | null;
flavours: Flavour[];
saving: boolean;
userLimits: UserLimits;
@@ -2432,37 +2471,39 @@ function EntryModal({
onSave: (draft: EntryDraft) => void;
}) {
const firstFieldRef = useRef<HTMLInputElement>(null);
const initialFlavour = entry?.flavour ?? DEFAULT_FLAVOUR.name;
const activeDraft = entry ?? initialDraft;
const initialFlavour = activeDraft?.flavour ?? DEFAULT_FLAVOUR.name;
const [selectedFlavour, setSelectedFlavour] = useState(initialFlavour);
const [customFlavour, setCustomFlavour] = useState("");
const [customAccent, setCustomAccent] = useState(MATERIAL_ACCENTS.custom);
const [cans, setCans] = useState(entry?.cans.toString() ?? "1");
const [sizePreset, setSizePreset] = useState(sizeToPreset(entry?.sizeMl ?? 250));
const [customSize, setCustomSize] = useState(entry?.sizeMl.toString() ?? "250");
const [pricePerCan, setPricePerCan] = useState(entry?.pricePerCan.toString() ?? "1.75");
const [dateTime, setDateTime] = useState(formatLocalInput(entry ? new Date(entry.dateTime) : new Date()));
const [store, setStore] = useState(entry?.store ?? "");
const [notes, setNotes] = useState(entry?.notes ?? "");
const [sugarFree, setSugarFree] = useState(entry?.sugarFree ?? false);
const [caffeineOverride, setCaffeineOverride] = useState(entry?.caffeineMgPerCan?.toString() ?? "");
const [cans, setCans] = useState(activeDraft?.cans.toString() ?? "1");
const [sizePreset, setSizePreset] = useState(sizeToPreset(activeDraft?.sizeMl ?? 250));
const [customSize, setCustomSize] = useState(activeDraft?.sizeMl.toString() ?? "250");
const [pricePerCan, setPricePerCan] = useState(activeDraft?.pricePerCan.toString() ?? "1.75");
const [dateTime, setDateTime] = useState(formatLocalInput(activeDraft ? new Date(activeDraft.dateTime) : new Date()));
const [store, setStore] = useState(activeDraft?.store ?? "");
const [notes, setNotes] = useState(activeDraft?.notes ?? "");
const [sugarFree, setSugarFree] = useState(activeDraft?.sugarFree ?? false);
const [caffeineOverride, setCaffeineOverride] = useState(activeDraft?.caffeineMgPerCan?.toString() ?? "");
useEffect(() => {
if (!open) return;
const editingCustom = entry && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === entry.flavour);
setSelectedFlavour(editingCustom ? entry.flavour : entry?.flavour ?? DEFAULT_FLAVOUR.name);
setCustomFlavour(editingCustom ? entry.flavour : "");
setCustomAccent(entry?.flavourAccent ?? MATERIAL_ACCENTS.custom);
setCans(entry?.cans.toString() ?? "1");
setSizePreset(sizeToPreset(entry?.sizeMl ?? 250));
setCustomSize(entry?.sizeMl.toString() ?? "250");
setPricePerCan(entry?.pricePerCan.toString() ?? defaultPriceForSize(250).toString());
setDateTime(formatLocalInput(entry ? new Date(entry.dateTime) : new Date()));
setStore(entry?.store ?? "");
setNotes(entry?.notes ?? "");
setSugarFree(entry?.sugarFree ?? false);
setCaffeineOverride(entry?.caffeineMgPerCan?.toString() ?? "");
const draft = entry ?? initialDraft;
const editingCustom = draft && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === draft.flavour);
setSelectedFlavour(editingCustom ? draft.flavour : draft?.flavour ?? DEFAULT_FLAVOUR.name);
setCustomFlavour(editingCustom ? draft.flavour : "");
setCustomAccent(draft?.flavourAccent ?? MATERIAL_ACCENTS.custom);
setCans(draft?.cans.toString() ?? "1");
setSizePreset(sizeToPreset(draft?.sizeMl ?? 250));
setCustomSize(draft?.sizeMl.toString() ?? "250");
setPricePerCan(draft?.pricePerCan.toString() ?? defaultPriceForSize(250).toFixed(2));
setDateTime(formatLocalInput(draft ? new Date(draft.dateTime) : new Date()));
setStore(draft?.store ?? "");
setNotes(draft?.notes ?? "");
setSugarFree(draft?.sugarFree ?? false);
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
window.setTimeout(() => firstFieldRef.current?.focus(), 80);
}, [entry, open]);
}, [entry, initialDraft, open]);
useEffect(() => {
if (!open) return;
@@ -2503,7 +2544,7 @@ function EntryModal({
store: store.trim(),
sugarFree: sugarFree || Boolean(meta.sugarFree),
caffeineMgPerCan: override,
source: entry?.source ?? "manual",
source: entry?.source ?? initialDraft?.source ?? "manual",
};
}, [
open,
@@ -2521,6 +2562,7 @@ function EntryModal({
sizePreset,
caffeineOverride,
entry?.source,
initialDraft?.source,
]);
const draftLimitCheck = useMemo(() => {
+60
View File
@@ -0,0 +1,60 @@
import { Edit3, Plus, X } from "lucide-react";
import { currency, wholeNumber } from "../lib/metrics";
import { productCaffeineMg } from "../lib/barcodeLookup";
import type { ResolvedBarcodeProduct } from "../types";
export function BarcodeProductPreview({
barcode,
busy,
product,
onAddNow,
onCancel,
onEdit,
}: {
barcode: string;
busy: boolean;
product: ResolvedBarcodeProduct;
onAddNow: () => void;
onCancel: () => void;
onEdit: () => void;
}) {
const caffeineMg = productCaffeineMg(product);
return (
<section
className="rounded-3xl border border-cyan-200/20 bg-cyan-200/10 p-4 shadow-sm"
aria-labelledby="barcode-product-title"
>
<div className="flex items-start gap-3">
<span
className="mt-1 h-4 w-4 shrink-0 rounded-full shadow-sm"
style={{ backgroundColor: product.flavourAccent }}
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-100">Barcode matched</p>
<h3 id="barcode-product-title" className="mt-1 text-xl font-semibold tracking-tight text-white">
Found: Red Bull {product.flavourName}, {product.sizeMl}ml, {currency.format(product.pricePerCan)},{" "}
{wholeNumber.format(caffeineMg)}mg caffeine
</h3>
<p className="mt-2 break-all text-sm text-slate-300">Barcode {barcode}</p>
</div>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<button className="primary-button justify-center" type="button" onClick={onAddNow} disabled={busy}>
<Plus size={17} aria-hidden="true" />
Add now
</button>
<button className="secondary-button justify-center" type="button" onClick={onEdit} disabled={busy}>
<Edit3 size={17} aria-hidden="true" />
Edit before adding
</button>
<button className="secondary-button justify-center" type="button" onClick={onCancel} disabled={busy}>
<X size={17} aria-hidden="true" />
Cancel
</button>
</div>
</section>
);
}
+539
View File
@@ -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()];
}
+6
View File
@@ -0,0 +1,6 @@
import type { BarcodeSeedProduct } from "../types";
import verifiedBarcodes from "./verified-barcodes.json";
// Verified retail barcodes only. Add rows here via verified-barcodes.json so
// the frontend seed data and Appwrite setup script stay aligned.
export const BUILT_IN_BARCODE_PRODUCTS = verifiedBarcodes as Record<string, BarcodeSeedProduct>;
+17 -12
View File
@@ -1,20 +1,25 @@
import type { Flavour } from "../types";
export const BUILT_IN_FLAVOURS: Flavour[] = [
{ name: "Original", accent: "#00A7FF" },
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
{ name: "Ruby", accent: "#C3093B" },
{ name: "Iced Vanilla", accent: "#49adbe" },
{ name: "Tropical", accent: "#FFC247" },
{ name: "Watermelon", accent: "#FF355E" },
{ name: "Original", accent: "#282874" },
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
{ name: "Ruby", accent: "#B50045" },
{ name: "Iced Vanilla", accent: "#53B2C2" },
{ name: "Tropical", accent: "#FFCB04" },
{ name: "Cherry Edition", accent: "#D81B60" },
{ name: "Apricot Edition", accent: "#F3911B" },
{ name: "Lilac Sugarfree", accent: "#7D62CE", sugarFree: true },
{ name: "Pink Sugarfree", accent: "#E77BAB", sugarFree: true },
{ name: "Watermelon", accent: "#E6301F" },
{ name: "Blueberry", accent: "#496DFF" },
{ name: "Coconut Berry", accent: "#D8F9FF" },
{ name: "Peach", accent: "#FF9B63" },
{ name: "Juneberry", accent: "#9C73FF" },
{ name: "Coconut Berry", accent: "#0070B8" },
{ name: "Peach", accent: "#E24585" },
{ name: "Juneberry", accent: "#0085C8" },
{ name: "Dragon Fruit", accent: "#FF3DBD" },
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
{ name: "Winter Edition", accent: "#7CE7FF" },
{ name: "Summer Edition", accent: "#f0e53b" },
{ name: "Curuba Elderflower", accent: "#78B941" },
{ name: "Winter Edition", accent: "#BF1431" },
{ name: "Summer Edition", accent: "#F2E853" },
{ name: "Other", accent: "#AEB9C7" },
];
+101 -99
View File
@@ -51,147 +51,149 @@ export const APP_THEMES: AppTheme[] = [
tertiary: "#ffd8e7",
}),
theme("original", "Original", "flavour", "#00a7ff", {
primary: "#0077c8",
secondary: "#00a7ff",
tertiary: "#1e3264",
theme("original", "Original", "flavour", "#282874", {
primary: "#282874",
secondary: "#efefef",
tertiary: "#d4af37",
tokens: {
chartSecondary: "#e6301f",
},
}),
theme("zero", "Zero", "flavour", "#2a2a2a", {
primary: "#2a2a2a",
secondary: "#5c5c5c",
tertiary: "#8a8a8a",
dark: true,
theme("zero", "Zero", "flavour", "#b1d0ee", {
primary: "#b1d0ee",
secondary: "#efefef",
tertiary: "#e6301f",
}),
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
primary: "#d4c400",
secondary: "#f0e53b",
tertiary: "#ffc247",
primary: "#f2e853",
secondary: "#efefef",
tertiary: "#8a8f98",
}),
theme("cherry", "Cherry Edition", "flavour", "#e40046", {
primary: "#c3093b",
secondary: "#e40046",
tertiary: "#ff6b8a",
theme("cherry", "Cherry Edition", "flavour", "#d81b60", {
primary: "#d81b60",
secondary: "#efefef",
tertiary: "#b50045",
}),
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
primary: "#e85d8a",
secondary: "#ffb3c6",
tertiary: "#ffd8e7",
}),
theme("apple", "Apple Edition", "flavour", "#78be20", {
primary: "#5a9a12",
secondary: "#78be20",
tertiary: "#a8d84a",
theme("apple", "Apple Edition", "flavour", "#bf1431", {
primary: "#bf1431",
secondary: "#f6c300",
tertiary: "#f3911b",
}),
theme("peach", "Peach Edition", "flavour", "#ff9b63", {
primary: "#e87a3a",
secondary: "#ff9b63",
tertiary: "#ffc9a3",
theme("peach", "Peach Edition", "flavour", "#e24585", {
primary: "#e24585",
secondary: "#efefef",
tertiary: "#d6417e",
}),
theme("ice", "Ice Edition", "flavour", "#49adbe", {
primary: "#2d8a9a",
secondary: "#49adbe",
tertiary: "#7ce7ff",
primary: "#53b2c2",
secondary: "#efefef",
tertiary: "#49adbe",
}),
theme("blue-edition", "Blue Edition", "flavour", "#496dff", {
primary: "#3a52cc",
secondary: "#496dff",
tertiary: "#9c73ff",
theme("blue-edition", "Blue Edition", "flavour", "#0085c8", {
primary: "#0085c8",
secondary: "#efefef",
tertiary: "#ff73d1",
}),
theme("red-edition", "Red Edition", "flavour", "#ff355e", {
primary: "#e02045",
secondary: "#ff355e",
tertiary: "#ff6b8a",
theme("red-edition", "Red Edition", "flavour", "#e6301f", {
primary: "#e6301f",
secondary: "#efefef",
tertiary: "#78b941",
}),
theme("tropical", "Tropical Edition", "flavour", "#ffc247", {
primary: "#e0a820",
secondary: "#ffc247",
tertiary: "#ff9b63",
theme("tropical", "Tropical Edition", "flavour", "#ffcb04", {
primary: "#ffcb04",
secondary: "#efefef",
tertiary: "#f6c300",
}),
theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", {
primary: "#4ec4e0",
secondary: "#7ce7ff",
tertiary: "#d8f9ff",
theme("coconut", "Coconut Edition", "flavour", "#0070b8", {
primary: "#0070b8",
secondary: "#efefef",
tertiary: "#8a8f98",
}),
theme("green-edition", "Green Edition", "flavour", "#b7ff4a", {
primary: "#7acc20",
secondary: "#b7ff4a",
tertiary: "#d4ff8a",
theme("green-edition", "Green Edition", "flavour", "#78b941", {
primary: "#78b941",
secondary: "#efefef",
tertiary: "#f3911b",
}),
theme("apricot", "Apricot Edition", "flavour", "#ff8c42", {
primary: "#e06a20",
secondary: "#ff8c42",
tertiary: "#ffb87a",
theme("apricot", "Apricot Edition", "flavour", "#f3911b", {
primary: "#f3911b",
secondary: "#efefef",
tertiary: "#d6417e",
}),
theme("ruby", "Ruby Edition", "flavour", "#c3093b", {
primary: "#a00730",
secondary: "#c3093b",
tertiary: "#e04060",
theme("ruby", "Ruby Edition", "flavour", "#b50045", {
primary: "#b50045",
secondary: "#efefef",
tertiary: "#a3e635",
}),
theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", {
primary: "#8a9bb0",
secondary: "#c8d4e0",
tertiary: "#e7eef8",
theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", {
primary: "#009edf",
secondary: "#efefef",
tertiary: "#e6301f",
sugarFree: true,
}),
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", {
primary: "#c4c020",
secondary: "#e8e4a0",
tertiary: "#f0e53b",
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", {
primary: "#f2e853",
secondary: "#efefef",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", {
primary: "#6a9a30",
secondary: "#b8d4a0",
tertiary: "#78be20",
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", {
primary: "#bf1431",
secondary: "#f6c300",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", {
primary: "#d08050",
secondary: "#f0d0b8",
tertiary: "#ff9b63",
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", {
primary: "#e24585",
secondary: "#efefef",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", {
primary: "#4a9aaa",
secondary: "#b8e0e8",
tertiary: "#49adbe",
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", {
primary: "#53b2c2",
secondary: "#efefef",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", {
primary: "#9070c0",
secondary: "#d8c8f0",
tertiary: "#b898e0",
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", {
primary: "#7d62ce",
secondary: "#44c7b7",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", {
primary: "#d06090",
secondary: "#f0c8d8",
tertiary: "#ffb7d9",
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", {
primary: "#e77bab",
secondary: "#8a1f3d",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", {
primary: "#5060c0",
secondary: "#c8d0f8",
tertiary: "#496dff",
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", {
primary: "#0085c8",
secondary: "#efefef",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", {
primary: "#60b8d0",
secondary: "#d0f0f8",
tertiary: "#7ce7ff",
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", {
primary: "#0070b8",
secondary: "#efefef",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", {
primary: "#70a830",
secondary: "#d8f0b8",
tertiary: "#b7ff4a",
theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", {
primary: "#78b941",
secondary: "#efefef",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", {
primary: "#a03050",
secondary: "#f0c0c8",
tertiary: "#c3093b",
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", {
primary: "#b50045",
secondary: "#efefef",
tertiary: "#009edf",
sugarFree: true,
}),
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
+475
View File
@@ -0,0 +1,475 @@
{
"90162602": {
"flavourName": "Original",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL NON PMP - ORIGINAL 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-original-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode. Price uses tracker default."
},
"90493317": {
"flavourName": "Original",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL NON PMP - ORIGINAL 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-original-250ml/",
"variant": "current-pmp",
"notes": "Current GBP 1.75 PMP barcode."
},
"90457999": {
"flavourName": "Original",
"sizeMl": 250,
"pricePerCan": 1.65,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL NON PMP - ORIGINAL 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-original-250ml/",
"variant": "older-pmp",
"notes": "Older GBP 1.65 PMP barcode."
},
"90162800": {
"flavourName": "Sugar Free",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL NON PMP - SUGAR FREE",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-sugar-free/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode. Price uses tracker default."
},
"90496066": {
"flavourName": "Sugar Free",
"sizeMl": 250,
"pricePerCan": 1.7,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL NON PMP - SUGAR FREE",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-sugar-free/",
"variant": "current-pmp",
"notes": "Current GBP 1.70 PMP barcode."
},
"90457982": {
"flavourName": "Sugar Free",
"sizeMl": 250,
"pricePerCan": 1.6,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL NON PMP - SUGAR FREE",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-sugar-free/",
"variant": "older-pmp",
"notes": "Older GBP 1.60 PMP barcode."
},
"90415425": {
"flavourName": "Zero",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL ZERO 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-zero-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode. Price uses tracker default."
},
"90496011": {
"flavourName": "Zero",
"sizeMl": 250,
"pricePerCan": 1.7,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL ZERO 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-zero-250ml/",
"variant": "current-pmp",
"notes": "Current GBP 1.70 PMP barcode."
},
"90457890": {
"flavourName": "Zero",
"sizeMl": 250,
"pricePerCan": 1.6,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL ZERO 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-zero-250ml/",
"variant": "older-pmp",
"notes": "Older GBP 1.60 PMP barcode."
},
"90493423": {
"flavourName": "Cherry Edition",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Bestway Wholesale",
"sourceName": "Red Bull Spring Edition Cherry Sakura Energy Drink 250ml",
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/833691-1",
"variant": "pmp",
"notes": "PMP barcode verified. Plain can barcode not publicly verified in the supplied source list."
},
"90493539": {
"flavourName": "Summer Edition",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Bestway Wholesale",
"sourceName": "Red Bull Summer Edition Citrus Zest Energy Drink 250ml",
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/833324-1",
"variant": "pmp",
"notes": "PMP barcode verified. Sugarfree Citrus Zest barcode was not publicly verified in the supplied source list."
},
"90486449": {
"flavourName": "Winter Edition",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "BB Foodservice",
"sourceName": "Red Bull Winter Edition Fuji Apple & Ginger Energy Drink",
"sourceUrl": "https://www.bbfoodservice.co.uk/product/830604-1",
"variant": "meal-deal-or-no-price",
"notes": "Fuji Apple & Ginger listing mapped to existing Winter Edition flavour."
},
"90493485": {
"flavourName": "Winter Edition",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "BB Foodservice",
"sourceName": "Red Bull Winter Edition Fuji Apple & Ginger Energy Drink",
"sourceUrl": "https://www.bbfoodservice.co.uk/product/830604-1",
"variant": "pmp",
"notes": "PMP barcode mapped to existing Winter Edition flavour."
},
"90493355": {
"flavourName": "Peach",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Bestway Wholesale",
"sourceName": "Red Bull Peach Edition White Peach Energy Drink 250ml",
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832794-1",
"variant": "current-pmp",
"notes": "Current PMP barcode verified. Plain can barcode not publicly verified in the supplied source list."
},
"90474576": {
"flavourName": "Peach",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Bestway Wholesale",
"sourceName": "Red Bull Peach Edition White Peach Energy Drink 250ml",
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832794-1",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90457449": {
"flavourName": "Iced Vanilla",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - ICED VANILLA BERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-iced-vanilla-berry-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493324": {
"flavourName": "Iced Vanilla",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - ICED VANILLA BERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-iced-vanilla-berry-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90486234": {
"flavourName": "Iced Vanilla",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - ICED VANILLA BERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-iced-vanilla-berry-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90454035": {
"flavourName": "Juneberry",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - JUNEBERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-juneberry-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493737": {
"flavourName": "Juneberry",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - JUNEBERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-juneberry-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90474095": {
"flavourName": "Juneberry",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - JUNEBERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-juneberry-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90446412": {
"flavourName": "Watermelon",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - WATERMELON 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-watermelon-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493713": {
"flavourName": "Watermelon",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - WATERMELON 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-watermelon-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90457975": {
"flavourName": "Watermelon",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - WATERMELON 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-watermelon-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90415739": {
"flavourName": "Tropical",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - TROPICAL 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-tropical-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493348": {
"flavourName": "Tropical",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - TROPICAL 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-tropical-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90474057": {
"flavourName": "Tropical",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - TROPICAL 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-tropical-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90435348": {
"flavourName": "Coconut Berry",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - COCONUT & BERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-coconut-berry-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493720": {
"flavourName": "Coconut Berry",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - COCONUT & BERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-coconut-berry-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90457951": {
"flavourName": "Coconut Berry",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - COCONUT & BERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-coconut-berry-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90456831": {
"flavourName": "Curuba Elderflower",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "Red Bull - SUMMER CARUBA 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-summer-caruba-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode appears under older Caruba naming."
},
"90493362": {
"flavourName": "Curuba Elderflower",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "Red Bull - SUMMER CARUBA 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-summer-caruba-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90474064": {
"flavourName": "Curuba Elderflower",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "Red Bull - SUMMER CARUBA 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-summer-caruba-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90453168": {
"flavourName": "Apricot Edition",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "Red Bull - APRICOT & STRAWBERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-apricot-strawberry-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493300": {
"flavourName": "Apricot Edition",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "Red Bull - APRICOT & STRAWBERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-apricot-strawberry-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90457968": {
"flavourName": "Apricot Edition",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "Red Bull - APRICOT & STRAWBERRY 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-apricot-strawberry-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90454899": {
"flavourName": "Ruby",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Intamarque",
"sourceName": "Red Bull The Ruby Edition Spiced Pear Energy Drink 250ml",
"sourceUrl": "https://intamarquewholesale.com/products/red-bull-the-ruby-edition-spiced-pear-energy-drink-250ml",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493560": {
"flavourName": "Ruby",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Intamarque",
"sourceName": "Red Bull The Ruby Edition Spiced Pear Energy Drink 250ml",
"sourceUrl": "https://intamarquewholesale.com/products/red-bull-the-ruby-edition-spiced-pear-energy-drink-250ml",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90474088": {
"flavourName": "Ruby",
"sizeMl": 250,
"pricePerCan": 1.75,
"verifiedBy": "Intamarque",
"sourceName": "Red Bull The Ruby Edition Spiced Pear Energy Drink 250ml",
"sourceUrl": "https://intamarquewholesale.com/products/red-bull-the-ruby-edition-spiced-pear-energy-drink-250ml",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90456985": {
"flavourName": "Pink Sugarfree",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
"variant": "meal-deal-or-no-price",
"notes": "Verified non-PMP barcode."
},
"90493379": {
"flavourName": "Pink Sugarfree",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
"variant": "current-pmp",
"notes": "Current PMP barcode verified."
},
"90474071": {
"flavourName": "Pink Sugarfree",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90456978": {
"flavourName": "Pink Sugarfree",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Brand Factory Ltd",
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
"variant": "pmp",
"notes": "Additional PMP barcode verified."
},
"90493294": {
"flavourName": "Lilac Sugarfree",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Bestway Wholesale",
"sourceName": "Red Bull Lilac Edition Sugarfree Grapefruit & Blossom Energy Drink",
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832789-1",
"variant": "current-pmp",
"notes": "PMP barcode verified. Plain can barcode not publicly verified in the supplied source list."
},
"90474774": {
"flavourName": "Lilac Sugarfree",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Bestway Wholesale",
"sourceName": "Red Bull Lilac Edition Sugarfree Grapefruit & Blossom Energy Drink",
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832789-1",
"variant": "older-pmp",
"notes": "Older PMP barcode verified."
},
"90486067": {
"flavourName": "Lilac Sugarfree",
"sizeMl": 250,
"pricePerCan": 1.75,
"sugarFree": true,
"verifiedBy": "Bestway Wholesale",
"sourceName": "Red Bull Lilac Edition Sugarfree Grapefruit & Blossom Energy Drink",
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832789-1",
"variant": "pmp",
"notes": "Additional PMP barcode verified."
}
}
+1
View File
@@ -9,6 +9,7 @@ export const appwriteConfig = {
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
};
+146
View File
@@ -0,0 +1,146 @@
import type { Models } from "appwrite";
import type { BarcodeLookupCatalog, BarcodeProductDraft, BarcodeSeedProduct, UserBarcodeMapping } from "../types";
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
import { normalizeBarcode } from "./barcodeLookup";
type BarcodeRowScope = "verified" | "user";
type BarcodeRow = Models.Row & {
scope: BarcodeRowScope;
ownerUserId?: string;
barcode: string;
flavourName: string;
sizeMl: number;
pricePerCan: number;
sugarFree: boolean;
caffeineMgPerCan?: number;
verifiedBy?: string;
sourceName?: string;
sourceUrl?: string;
variant?: string;
notes?: string;
};
export async function listBarcodeCatalog(): Promise<BarcodeLookupCatalog> {
const verifiedProducts: Record<string, BarcodeSeedProduct> = {};
const userMappings: UserBarcodeMapping[] = [];
const limit = 200;
let offset = 0;
while (true) {
const response = await tablesDB.listRows<BarcodeRow>({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.barcodeCollectionId,
queries: [Query.orderAsc("barcode"), Query.limit(limit), Query.offset(offset)],
});
response.rows.forEach((row) => {
if (row.scope === "verified") {
verifiedProducts[row.barcode] = fromVerifiedRow(row);
return;
}
userMappings.push(fromUserRow(row));
});
if (response.rows.length < limit) break;
offset += limit;
}
return { verifiedProducts, userMappings };
}
export async function upsertCloudUserBarcodeMapping(
userId: string,
barcodeValue: string,
product: BarcodeProductDraft,
) {
const barcode = normalizeBarcode(barcodeValue);
const existing = await findUserBarcodeRow(userId, barcode);
const data = toUserRowData(userId, barcode, product);
if (existing) {
const row = await tablesDB.updateRow<BarcodeRow>({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.barcodeCollectionId,
rowId: existing.$id,
data,
permissions: userRowPermissions(userId),
});
return fromUserRow(row);
}
const row = await tablesDB.createRow<BarcodeRow>({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.barcodeCollectionId,
rowId: ID.unique(),
data,
permissions: userRowPermissions(userId),
});
return fromUserRow(row);
}
async function findUserBarcodeRow(userId: string, barcode: string) {
const response = await tablesDB.listRows<BarcodeRow>({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.barcodeCollectionId,
queries: [
Query.equal("scope", "user"),
Query.equal("ownerUserId", userId),
Query.equal("barcode", barcode),
Query.limit(1),
],
});
return response.rows[0] ?? null;
}
function fromVerifiedRow(row: BarcodeRow): BarcodeSeedProduct {
return {
flavourName: row.flavourName,
sizeMl: row.sizeMl,
pricePerCan: row.pricePerCan,
sugarFree: row.sugarFree,
caffeineMgPerCan: row.caffeineMgPerCan,
verifiedBy: row.verifiedBy || "Verified source",
sourceName: row.sourceName,
sourceUrl: row.sourceUrl,
variant: row.variant,
notes: row.notes,
};
}
function fromUserRow(row: BarcodeRow): UserBarcodeMapping {
return {
barcode: row.barcode,
flavourName: row.flavourName,
sizeMl: row.sizeMl,
pricePerCan: row.pricePerCan,
sugarFree: row.sugarFree,
caffeineMgPerCan: row.caffeineMgPerCan,
createdAt: row.$createdAt,
updatedAt: row.$updatedAt,
};
}
function toUserRowData(userId: string, barcode: string, product: BarcodeProductDraft) {
return {
scope: "user" as const,
ownerUserId: userId,
barcode,
flavourName: product.flavourName,
sizeMl: product.sizeMl,
pricePerCan: product.pricePerCan,
sugarFree: Boolean(product.sugarFree),
caffeineMgPerCan: product.caffeineMgPerCan,
verifiedBy: "User saved mapping",
sourceName: "",
sourceUrl: "",
variant: "user",
notes: "",
};
}
function userRowPermissions(userId: string) {
const role = Role.user(userId);
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
}
+90
View File
@@ -0,0 +1,90 @@
import { BUILT_IN_BARCODE_PRODUCTS } from "../data/barcodes";
import { BUILT_IN_FLAVOURS, flavourMeta } from "../data/flavours";
import { caffeinePerCan } from "./metrics";
import type {
BarcodeLookupCatalog,
BarcodeLookupResult,
BarcodeProductDraft,
ResolvedBarcodeProduct,
UserBarcodeMapping,
EntryDraft,
} from "../types";
const knownFlavourNames = new Set(BUILT_IN_FLAVOURS.map((flavour) => flavour.name));
export function normalizeBarcode(value: string) {
return value.replace(/\D/g, "");
}
export function lookupBarcode(
rawBarcode: string,
catalogOrUserMappings: BarcodeLookupCatalog | UserBarcodeMapping[] = [],
): BarcodeLookupResult {
const catalog = Array.isArray(catalogOrUserMappings)
? { userMappings: catalogOrUserMappings }
: catalogOrUserMappings;
const userMappings = catalog.userMappings ?? [];
const verifiedProducts = catalog.verifiedProducts ?? BUILT_IN_BARCODE_PRODUCTS;
const barcode = normalizeBarcode(rawBarcode);
if (!barcode) {
return { status: "unknown", barcode: rawBarcode.trim() };
}
const userMapping = userMappings.find((mapping) => mapping.barcode === barcode);
if (userMapping) {
return { status: "user", barcode, product: resolveProduct(userMapping, "user") };
}
const seedProduct = verifiedProducts[barcode];
if (!seedProduct) {
return { status: "unknown", barcode };
}
if (!knownFlavourNames.has(seedProduct.flavourName)) {
return {
status: "partial",
barcode,
product: seedProduct,
reason: "This barcode has product data, but its flavour is not in the built-in Red Bull list yet.",
};
}
return { status: "known", barcode, product: resolveProduct(seedProduct, "built-in") };
}
export function resolveProduct(
product: BarcodeProductDraft,
source: ResolvedBarcodeProduct["source"],
): ResolvedBarcodeProduct {
const meta = flavourMeta(product.flavourName);
return {
...product,
flavourAccent: meta.accent,
sugarFree: product.sugarFree ?? Boolean(meta.sugarFree),
caffeineMgPerCan: product.caffeineMgPerCan,
source,
};
}
export function barcodeProductToEntryDraft(
product: ResolvedBarcodeProduct,
barcode: string,
): EntryDraft {
return {
cans: 1,
flavour: product.flavourName,
flavourAccent: product.flavourAccent,
sizeMl: product.sizeMl,
pricePerCan: product.pricePerCan,
dateTime: new Date().toISOString(),
notes: `Barcode scan: ${barcode}`,
store: "",
sugarFree: Boolean(product.sugarFree),
caffeineMgPerCan: product.caffeineMgPerCan,
source: "manual",
};
}
export function productCaffeineMg(product: BarcodeProductDraft) {
return caffeinePerCan(product.sizeMl, product.caffeineMgPerCan);
}
+267
View File
@@ -0,0 +1,267 @@
import {
BarcodeFormat,
BrowserCodeReader,
BrowserMultiFormatReader,
type IScannerControls,
} from "@zxing/browser";
import { normalizeBarcode } from "./barcodeLookup";
export type BarcodeScannerErrorCode =
| "camera-denied"
| "no-camera"
| "unsupported"
| "camera-in-use"
| "unknown";
export type BarcodeScannerError = {
code: BarcodeScannerErrorCode;
message: string;
};
export type BarcodeScanResult = {
value: string;
format: string;
};
export type BarcodeScannerController = {
mode: "native" | "zxing";
stop: () => void;
};
type NativeBarcode = {
rawValue?: string;
format?: string;
};
type NativeBarcodeDetector = {
detect: (source: HTMLVideoElement) => Promise<NativeBarcode[]>;
};
type NativeBarcodeDetectorConstructor = new (options?: {
formats?: string[];
}) => NativeBarcodeDetector;
type WindowWithBarcodeDetector = Window & {
BarcodeDetector?: NativeBarcodeDetectorConstructor & {
getSupportedFormats?: () => Promise<string[]>;
};
};
const NATIVE_FORMATS = ["ean_13", "ean_8", "upc_a", "upc_e"];
const ZXING_FORMATS = [
BarcodeFormat.EAN_13,
BarcodeFormat.EAN_8,
BarcodeFormat.UPC_A,
BarcodeFormat.UPC_E,
];
const SCAN_CONSTRAINTS: MediaStreamConstraints = {
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
};
export async function startBarcodeScanner(
videoElement: HTMLVideoElement,
onResult: (result: BarcodeScanResult) => void,
onError: (error: BarcodeScannerError) => void,
): Promise<BarcodeScannerController> {
if (!navigator.mediaDevices?.getUserMedia) {
throw toScannerError(new Error("Camera access is not supported in this browser."));
}
if (await supportsNativeBarcodeDetector()) {
try {
return await startNativeBarcodeScanner(videoElement, onResult);
} catch (error) {
stopVideoStream(videoElement);
if (isCameraAccessError(error)) {
throw toScannerError(error);
}
}
}
return startZxingBarcodeScanner(videoElement, onResult, onError);
}
export function stopVideoStream(videoElement: HTMLVideoElement | null) {
if (!videoElement) return;
const stream = videoElement.srcObject;
if (stream instanceof MediaStream) {
stream.getTracks().forEach((track) => track.stop());
}
videoElement.pause();
videoElement.removeAttribute("src");
videoElement.srcObject = null;
videoElement.load();
}
export function scannerErrorMessage(code: BarcodeScannerErrorCode) {
switch (code) {
case "camera-denied":
return "Camera permission was denied. Allow camera access, then try scanning again.";
case "no-camera":
return "No camera was found on this device. You can type the barcode instead.";
case "camera-in-use":
return "The camera looks busy in another app or browser tab. Close it there, then try again.";
case "unsupported":
return "Barcode scanning is not supported in this browser. You can type the barcode instead.";
case "unknown":
default:
return "The scanner could not start. You can type the barcode instead.";
}
}
function startNativeBarcodeScanner(
videoElement: HTMLVideoElement,
onResult: (result: BarcodeScanResult) => void,
): Promise<BarcodeScannerController> {
return new Promise((resolve, reject) => {
let stopped = false;
let animationFrame = 0;
let stream: MediaStream | null = null;
async function start() {
try {
stream = await navigator.mediaDevices.getUserMedia(SCAN_CONSTRAINTS);
videoElement.srcObject = stream;
videoElement.setAttribute("playsinline", "true");
videoElement.muted = true;
await videoElement.play();
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
if (!Detector) {
throw new Error("Native barcode detector unavailable.");
}
const detector = new Detector({ formats: NATIVE_FORMATS });
const stop = () => {
stopped = true;
window.cancelAnimationFrame(animationFrame);
stopVideoStream(videoElement);
};
const scan = async () => {
if (stopped) return;
try {
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
const barcodes = await detector.detect(videoElement);
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
if (barcode?.rawValue) {
onResult({
value: normalizeBarcode(barcode.rawValue),
format: barcode.format ?? "unknown",
});
}
}
} finally {
if (!stopped) animationFrame = window.requestAnimationFrame(() => void scan());
}
};
animationFrame = window.requestAnimationFrame(() => void scan());
resolve({ mode: "native", stop });
} catch (error) {
if (stream) stream.getTracks().forEach((track) => track.stop());
reject(error);
}
}
void start();
});
}
async function startZxingBarcodeScanner(
videoElement: HTMLVideoElement,
onResult: (result: BarcodeScanResult) => void,
onError: (error: BarcodeScannerError) => void,
): Promise<BarcodeScannerController> {
const reader = new BrowserMultiFormatReader();
reader.possibleFormats = ZXING_FORMATS;
try {
const controls = await reader.decodeFromConstraints(
SCAN_CONSTRAINTS,
videoElement,
(result, error) => {
if (result) {
onResult({
value: normalizeBarcode(result.getText()),
format: BarcodeFormat[result.getBarcodeFormat()] ?? "unknown",
});
return;
}
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
onError(toScannerError(error));
}
},
);
return {
mode: "zxing",
stop: () => stopZxingScanner(controls, videoElement),
};
} catch (error) {
stopVideoStream(videoElement);
BrowserCodeReader.releaseAllStreams();
throw toScannerError(error);
}
}
function stopZxingScanner(controls: IScannerControls, videoElement: HTMLVideoElement) {
controls.stop();
BrowserCodeReader.releaseAllStreams();
stopVideoStream(videoElement);
}
async function supportsNativeBarcodeDetector() {
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
if (!Detector) return false;
if (!Detector.getSupportedFormats) return true;
try {
const formats = await Detector.getSupportedFormats();
return NATIVE_FORMATS.some((format) => formats.includes(format));
} catch {
return false;
}
}
function isCameraAccessError(error: unknown) {
if (!(error instanceof DOMException)) return false;
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
}
function toScannerError(error: unknown): BarcodeScannerError {
if (error instanceof DOMException) {
if (error.name === "NotAllowedError" || error.name === "SecurityError") {
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
}
if (error.name === "NotFoundError" || error.name === "OverconstrainedError") {
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
}
if (error.name === "NotReadableError" || error.name === "TrackStartError") {
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
}
}
if (error instanceof Error && /not.?found|video input|requested device/i.test(error.message)) {
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
}
if (error instanceof Error && /not.?allowed|permission|denied/i.test(error.message)) {
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
}
if (error instanceof Error && /in use|busy|could not start video source/i.test(error.message)) {
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
}
if (error instanceof Error && /not supported|unsupported|barcode detector unavailable/i.test(error.message)) {
return { code: "unsupported", message: scannerErrorMessage("unsupported") };
}
return { code: "unknown", message: scannerErrorMessage("unknown") };
}
+2 -1
View File
@@ -92,6 +92,7 @@ export function useCoachSession(
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user.$id]);
const upsertChatState = useCallback((chat: CoachChat) => {
@@ -240,7 +241,7 @@ export function useCoachSession(
setBusy(false);
}
},
[activeChat, busy, dashboard, entries, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, withAssistantMessage],
[activeChat, busy, dashboard, entries, limitCheck, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, userLimits, withAssistantMessage],
);
const queuePrompt = useCallback((prompt: string) => {
+63
View File
@@ -0,0 +1,63 @@
import { normalizeBarcode } from "./barcodeLookup";
import type { BarcodeProductDraft, UserBarcodeMapping } from "../types";
const STORAGE_PREFIX = "red-bull-barcode-mappings:v1";
export function loadUserBarcodeMappings(userId: string) {
const raw = localStorage.getItem(storageKey(userId));
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isUserBarcodeMapping);
} catch {
return [];
}
}
export function saveUserBarcodeMappings(userId: string, mappings: UserBarcodeMapping[]) {
localStorage.setItem(storageKey(userId), JSON.stringify(mappings));
}
export function upsertUserBarcodeMapping(
userId: string,
barcodeValue: string,
product: BarcodeProductDraft,
) {
const barcode = normalizeBarcode(barcodeValue);
const now = new Date().toISOString();
const mappings = loadUserBarcodeMappings(userId);
const existing = mappings.find((mapping) => mapping.barcode === barcode);
const nextMapping: UserBarcodeMapping = {
...product,
barcode,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const nextMappings = existing
? mappings.map((mapping) => (mapping.barcode === barcode ? nextMapping : mapping))
: [...mappings, nextMapping];
saveUserBarcodeMappings(userId, nextMappings);
return nextMapping;
}
function storageKey(userId: string) {
return `${STORAGE_PREFIX}:${userId}`;
}
function isUserBarcodeMapping(value: unknown): value is UserBarcodeMapping {
if (!value || typeof value !== "object") return false;
const mapping = value as Partial<UserBarcodeMapping>;
return (
typeof mapping.barcode === "string" &&
typeof mapping.flavourName === "string" &&
typeof mapping.sizeMl === "number" &&
typeof mapping.pricePerCan === "number" &&
typeof mapping.createdAt === "string" &&
typeof mapping.updatedAt === "string" &&
(mapping.sugarFree === undefined || typeof mapping.sugarFree === "boolean") &&
(mapping.caffeineMgPerCan === undefined || typeof mapping.caffeineMgPerCan === "number")
);
}
+51
View File
@@ -34,6 +34,57 @@ export type EntryDraft = Omit<
source?: RedBullEntry["source"];
};
export type BarcodeFormatName = "ean-13" | "ean-8" | "upc-a" | "upc-e" | "unknown";
export type BarcodeProductDraft = {
flavourName: string;
sizeMl: number;
pricePerCan: number;
sugarFree?: boolean;
caffeineMgPerCan?: number;
};
export type ResolvedBarcodeProduct = BarcodeProductDraft & {
flavourAccent: string;
source: "built-in" | "user";
};
export type BarcodeSeedProduct = BarcodeProductDraft & {
verifiedBy: string;
sourceName?: string;
sourceUrl?: string;
notes?: string;
variant?: string;
};
export type UserBarcodeMapping = BarcodeProductDraft & {
barcode: string;
createdAt: string;
updatedAt: string;
};
export type BarcodeLookupCatalog = {
verifiedProducts?: Record<string, BarcodeSeedProduct>;
userMappings?: UserBarcodeMapping[];
};
export type BarcodeLookupResult =
| {
status: "known" | "user";
barcode: string;
product: ResolvedBarcodeProduct;
}
| {
status: "partial";
barcode: string;
product: BarcodeProductDraft;
reason: string;
}
| {
status: "unknown";
barcode: string;
};
export type Filters = {
flavour: string;
dateRange: DateFilter;
+1
View File
@@ -6,6 +6,7 @@ interface ImportMetaEnv {
readonly VITE_APPWRITE_DATABASE_ID?: string;
readonly VITE_APPWRITE_COLLECTION_ID?: string;
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string;
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
readonly VITE_OLLAMA_PROXY_URL?: string;