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
+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")
);
}