From ec9ea9d1f9bd63374eb2b74c00c4d0183ca72e03 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Wed, 27 May 2026 14:29:22 +0100 Subject: [PATCH] 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. --- APPWRITE_SETUP.md | 3 + package-lock.json | 46 ++ package.json | 1 + scripts/setup-appwrite.mjs | 70 +++ src/App.tsx | 146 +++--- src/components/BarcodeProductPreview.tsx | 60 +++ src/components/BarcodeScannerModal.tsx | 539 +++++++++++++++++++++++ src/data/barcodes.ts | 6 + src/data/flavours.ts | 29 +- src/data/themes.ts | 200 ++++----- src/data/verified-barcodes.json | 475 ++++++++++++++++++++ src/lib/appwrite.ts | 1 + src/lib/appwriteBarcodes.ts | 146 ++++++ src/lib/barcodeLookup.ts | 90 ++++ src/lib/barcodeScanner.ts | 267 +++++++++++ src/lib/useCoachSession.ts | 3 +- src/lib/userBarcodeMappings.ts | 63 +++ src/types.ts | 51 +++ src/vite-env.d.ts | 1 + 19 files changed, 2033 insertions(+), 164 deletions(-) create mode 100644 src/components/BarcodeProductPreview.tsx create mode 100644 src/components/BarcodeScannerModal.tsx create mode 100644 src/data/barcodes.ts create mode 100644 src/data/verified-barcodes.json create mode 100644 src/lib/appwriteBarcodes.ts create mode 100644 src/lib/barcodeLookup.ts create mode 100644 src/lib/barcodeScanner.ts create mode 100644 src/lib/userBarcodeMappings.ts diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index b7736e6..ec5e506 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -37,6 +37,7 @@ Configured defaults: - Database ID: `redbull_tracker` - Collection ID: `intake_entries` - Chat collection ID: `coach_chats` +- Barcode collection ID: `barcode_products` `client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`. @@ -86,6 +87,8 @@ So if the Console asks you to create a **table**, that is the same resource as t The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID. +The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions. + Create a database with ID: ```text diff --git a/package-lock.json b/package-lock.json index 4f3b2f5..40f071d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@vitejs/plugin-react": "^4.3.4", + "@zxing/browser": "^0.2.0", "appwrite": "^25.0.0", "exceljs": "^4.4.0", "framer-motion": "^11.18.2", @@ -1898,6 +1899,41 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@zxing/browser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz", + "integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.22.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz", + "integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.3.1" + }, + "engines": { + "node": ">= 24.0.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5079,6 +5115,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/package.json b/package.json index 7fec1a0..d877f09 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", + "@zxing/browser": "^0.2.0", "appwrite": "^25.0.0", "exceljs": "^4.4.0", "framer-motion": "^11.18.2", diff --git a/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs index b04777a..036e48c 100644 --- a/scripts/setup-appwrite.mjs +++ b/scripts/setup-appwrite.mjs @@ -1,6 +1,7 @@ /* global console, fetch, process, setTimeout */ import { existsSync, readFileSync } from "node:fs"; +import { URL } from "node:url"; const env = loadEnvFiles([".env", ".env.local"]); @@ -9,7 +10,11 @@ const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138"); const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker"); const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries"); const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats"); +const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products"); const apiKey = readEnv("APPWRITE_API_KEY", ""); +const verifiedBarcodeProducts = JSON.parse( + readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"), +); if (!apiKey) { throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); @@ -59,6 +64,34 @@ await retireLegacyChatColumns(chatTableId, [ "version", ]); await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]); +await ensureTable({ + tableId: barcodeTableId, + name: "Barcode products", + // Schema notes: + // - scope="verified" rows are seeded by this admin script and readable by signed-in users. + // - scope="user" rows are created by the browser SDK with per-user row permissions. + columns: [ + { kind: "string", key: "scope", size: 16, required: true }, + { kind: "string", key: "ownerUserId", size: 64, required: false }, + { kind: "string", key: "barcode", size: 32, required: true }, + { kind: "string", key: "flavourName", size: 128, required: true }, + { kind: "integer", key: "sizeMl", required: true }, + { kind: "float", key: "pricePerCan", required: true }, + { kind: "boolean", key: "sugarFree", required: true }, + { kind: "float", key: "caffeineMgPerCan", required: false }, + { kind: "string", key: "verifiedBy", size: 512, required: false }, + { kind: "string", key: "sourceName", size: 512, required: false }, + { kind: "string", key: "sourceUrl", size: 2048, required: false }, + { kind: "string", key: "variant", size: 64, required: false }, + { kind: "string", key: "notes", size: 2000, required: false }, + ], + indexes: [ + { key: "barcode", type: "key", columns: ["barcode"], orders: ["ASC"], lengths: [32] }, + { key: "scope_barcode", type: "key", columns: ["scope", "barcode"], orders: ["ASC", "ASC"], lengths: [16, 32] }, + { key: "user_barcode", type: "key", columns: ["ownerUserId", "barcode"], orders: ["ASC", "ASC"], lengths: [64, 32] }, + ], +}); +await seedVerifiedBarcodeProducts(barcodeTableId, verifiedBarcodeProducts); console.log("Appwrite database and tables ready."); @@ -156,6 +189,43 @@ async function ensureIndex(tableId, index) { console.log(`Index ${tableId}.${index.key} created.`); } +async function seedVerifiedBarcodeProducts(tableId, products) { + for (const [barcode, product] of Object.entries(products)) { + const rowId = `verified_${barcode}`; + const data = { + scope: "verified", + ownerUserId: "", + barcode, + flavourName: product.flavourName, + sizeMl: product.sizeMl, + pricePerCan: product.pricePerCan, + sugarFree: Boolean(product.sugarFree), + caffeineMgPerCan: product.caffeineMgPerCan, + verifiedBy: product.verifiedBy ?? "", + sourceName: product.sourceName ?? "", + sourceUrl: product.sourceUrl ?? "", + variant: product.variant ?? "", + notes: product.notes ?? "", + }; + const path = `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`; + const existing = await request("GET", path, undefined, [200, 404]); + + if (existing.status === 404) { + await request( + "POST", + `/tablesdb/${databaseId}/tables/${tableId}/rows`, + { rowId, data, permissions: ['read("users")'] }, + [201], + ); + console.log(`Verified barcode ${barcode} seeded.`); + continue; + } + + await request("PUT", path, { data, permissions: ['read("users")'] }, [200]); + console.log(`Verified barcode ${barcode} updated.`); + } +} + async function waitForColumns(tableId, keys) { const pending = new Set(keys); for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) { diff --git a/src/App.tsx b/src/App.tsx index fcfc88c..f7d9035 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(DEFAULT_FILTERS); const [activeView, setActiveView] = useState("overview"); const [isEntryModalOpen, setIsEntryModalOpen] = useState(false); + const [entryInitialDraft, setEntryInitialDraft] = useState(null); const [editingEntry, setEditingEntry] = useState(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} /> @@ -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() { { setIsEntryModalOpen(false); setEditingEntry(null); + setEntryInitialDraft(null); }} onSave={(draft) => void saveEntry(draft)} /> + setIsBarcodeScannerOpen(false)} + onEditBeforeAdding={editBarcodeDraft} + /> + -