Compare commits

...

3 Commits

Author SHA1 Message Date
9961 839d00aee1 Merge pull request 'feat: integrate barcode scanning functionality and create AGENTS.MD' (#2) from barcode into main
Reviewed-on: #2
2026-05-27 13:34:14 +00:00
Ned Halksworth 023ec1096f 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.
2026-05-27 14:29:22 +01:00
Ned Halksworth a7993af1d2 feat: add AGENTS.MD and CLAUDE.MD 2026-05-27 14:28:46 +01:00
21 changed files with 2473 additions and 164 deletions
+220
View File
@@ -0,0 +1,220 @@
# Project Overview
Red Bull Intake Tracker is a premium web-based tracking dashboard designed for tracking caffeine
and beverage consumption, with a strong focus on Red Bull products. Built using React, Vite, and
TypeScript, it allows users to record intake (amount, flavour, size, price, timestamp, and location),
monitor daily spending and caffeine limits, view structured trends and streaks, import/export
data via styled Excel or JSON formats, and engage in real-time encrypted-compatible dialogue with an
AI wellness coach powered by a serverless Ollama proxy endpoint. Synchronized dynamically with
Appwrite Cloud databases using secure row-level document permissions, it delivers a highly reactive,
personalized, and privacy-first self-tracking experience.
## Repository Structure
- `api/` Contains serverless backend handlers, including the API gateway proxy for Ollama chat endpoints.
- `dist/` Contains static HTML, JavaScript, and CSS bundle files output by the production build process.
- `node_modules/` Stores third-party library dependencies and packages managed by npm.
- `scripts/` Houses automation scripts, including database schema configuration tools for Appwrite.
- `src/` Contains client-side React source code, components, utility models, and stylesheets.
- `src/components/` Reusable UI panel elements, forms, and splash screen wrappers.
- `src/data/` Static configurations, including theme lists and built-in flavours mapping.
- `src/lib/` Business logic engines for calculations, file parsers, and Appwrite client connections.
## Build & Development Commands
Use the following shell-ready commands to install dependencies, run the application, lint the code,
and manage the cloud database.
### Dependency Installation
```bash
npm install
```
### Local Development Server
Starts a local development server at `http://localhost:5173`.
```bash
npm run dev
```
### Production Build & Bundling
Performs TypeScript diagnostic type-checks and compiles the application into the `dist/` directory.
```bash
npm run build
```
### Production Preview
Runs a local web server to preview the built production bundle.
```bash
npm run preview
```
### Code Linting
Runs ESLint over TypeScript files to identify syntax issues and code style warnings.
```bash
npm run lint
```
### Appwrite Cloud Database Setup
Automatically provisions the databases, tables, columns, and indexes on the configured Appwrite instance.
```bash
npm run setup:appwrite
```
### Automated Testing
> TODO: Add automated test suite command (e.g., `npm run test` using Vitest or Jest).
### Application Deployment
> TODO: Add production deployment pipeline command (e.g., Vercel, Netlify, or Docker deploy).
## Code Style & Conventions
- **Language**: TypeScript is strictly required for all UI components and logic scripts; JavaScript
is limited to serverless handlers and build scripting.
- **Strict Checks**: TypeScript's `strict` compiler option is enabled; avoid using `any` and ensure
all parameters and return values are explicitly typed.
- **Formatting**: Code should be formatted with 2-space indentation, trailing commas where supported,
and double quotes for JSX/TSX properties.
- **Linting**: Rules are governed by ESLint (`eslint.config.js`), extending the standard TypeScript
and React Hooks rulesets.
- **Naming Conventions**:
- React components and files use PascalCase (e.g., `CoachPanel.tsx`).
- Business logic, utilities, and helper hooks use camelCase (e.g., `appwriteEntries.ts`, `useCoachSession.ts`).
- Constants and static metadata arrays use UPPER_SNAKE_CASE (e.g., `BUILT_IN_FLAVOURS`).
- **Imports**: Prefer explicit ES module imports (`import { ... } from "..."`). Avoid wildcards.
- **Commit Messages**:
- > TODO: Define commit message guidelines and templates (e.g., Conventional Commits).
## Architecture Notes
### Flow Architecture Diagram
```mermaid
graph TD
subgraph Frontend [Vite React Client]
App[App.tsx - Core State & Shell]
Components[Onboarding, CoachPanel, Limits, etc.]
LibMetrics[metrics.ts & userLimits.ts]
LibIO[excel.ts & storage.ts]
LibTheme[themeTokens.ts & themes.ts]
HookCoach[useCoachSession.ts]
end
subgraph BackendAPI [API & Proxies]
ViteProxy[Vite Dev Server Middleware]
VercelHandler[api/ollama-chat.js Serverless Function]
end
subgraph External [External Services]
AppwriteCloud[Appwrite Cloud / TablesDB]
OllamaAPI[Ollama Cloud API]
end
App --> Components
App --> LibMetrics
App --> LibIO
App --> LibTheme
App --> HookCoach
HookCoach -- "/api/ollama-chat (Local)" --> ViteProxy
HookCoach -- "/api/ollama-chat (Prod)" --> VercelHandler
ViteProxy -- "Headers Auth" --> OllamaAPI
VercelHandler -- "Headers Auth" --> OllamaAPI
App --> AppwriteCloud
```
### Component Roles & Data Flow
1. **State Orchestration (`src/App.tsx`)**:
Acts as the monolithic hub of the frontend. It manages user authentication states, currently
selected views, active database operations, modals, onboarding triggers, and theme settings.
2. **Metrics & Limits (`src/lib/metrics.ts`, `src/lib/userLimits.ts`)**:
Process raw intakes to extract total cans, spendings, caffeine absorption, hydration estimates,
streaks, and coordinate warnings when user limits are breached or bedtime approaches.
3. **External Data Codecs (`src/lib/excel.ts`, `src/lib/storage.ts`)**:
Implement styled spreadsheet formatting with `exceljs`, data sanity validation, duplicate-aware
import preview engines, and local JSON backup/restore modules.
4. **Dynamic Styling (`src/lib/themeTokens.ts`, `src/data/themes.ts`)**:
Computes CSS tokens dynamically from a selection of Vocaloid or beverage-themed configurations,
writing variables into the document root for real-time visual styling modifications.
5. **Cloud Synced State (`src/lib/appwrite.ts`, `src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`)**:
Establishes client tunnels to Appwrite's serverless TablesDB backend, running row-secured
CRUD actions bound strictly to the current `userId`.
6. **AI Coach Chatbot (`src/lib/useCoachSession.ts`, `api/ollama-chat.js`)**:
A state-machine custom hook that pipelines user questions, injects historical intake aggregates,
and streams responses from DeepSeek through server-side Ollama proxy tunnels.
## Testing Strategy
The repository does not currently feature automated test files. Testing is executed manually.
### Unit & Integration Testing
- > TODO: Configure unit and integration tests (e.g., Vitest + React Testing Library) to validate
metrics computations, limits checks, and file importing codecs.
### End-to-End (E2E) Testing
- > TODO: Introduce E2E test suites (e.g., Playwright or Cypress) to cover authentication paths,
entry additions, theme switches, and chatbot conversation loops.
### Continuous Integration (CI)
- > TODO: Establish a GitHub Actions workflow pipeline to run linters, type checks, and tests on
every branch commit or pull request.
## Security & Compliance
- **Authentication**: Delegated entirely to Appwrite's built-in OAuth/Email-password protocols.
No user passwords or direct login credentials are saved inside the application state.
- **Client Security**: Client-side application calls only the Appwrite browser SDK. No administrative
or server-level API keys are ever shipped or exposed to the client.
- **Database Row Security**: All Appwrite tables have `Row Security` enabled. Users are granted
`create` permissions on the table level, but read, update, and delete actions require explicit
document permissions matching the user's specific ID (`user:{userId}`).
- **LLM API Security**: To avoid key exposure, the client connects to the proxy path `/api/ollama-chat`.
The secret `OLLAMA_API_KEY` is maintained exclusively in secure server-side environment variables.
- **Dependency Auditing**:
- > TODO: Add automated dependency checking (e.g., `npm audit` or Dependabot) in the CI pipeline.
- **Software Licensing**:
- > TODO: Add a standard LICENSE file (e.g., MIT, Apache-2.0) to explicitly detail terms of reuse.
## Agent Guardrails
- **Environment File Preservation**: Never edit, modify, or commit variables directly inside
`.env.local` or `.env` templates unless explicitly instructed by the user.
- **Credential Safety**: Never add, write, or hardcode API keys, access secrets, project keys, or
personal tokens into the codebase or configuration files.
- **Safe Directory Boundaries**: Do not add, write, or alter files inside administrative, system, or
auto-generated directories like `.git`, `.gemini`, `dist`, or `node_modules`.
- **Monolithic State Warnings**: `src/App.tsx` contains the core layout and view engine. Exercise
extreme care when making adjustments to prevent breaking the view transitions, auth hooks, or modals.
- **Database Alignment Rules**: Always verify that any changes to DB record structures or types are
mirrored across `src/types.ts`, Appwrite modules (`src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`),
and the migration runner `scripts/setup-appwrite.mjs`.
## Extensibility Hooks
- **Flavour Extensions**: New built-in Red Bull flavours, accent colors, and sugar-free rules can be
easily appended to the `BUILT_IN_FLAVOURS` array in `src/data/flavours.ts`.
- **UI Custom Themes**: Additional visual themes, including Vocaloid, seasonal, or custom branding,
can be integrated by adding definitions to the `APP_THEMES` array in `src/data/themes.ts`.
- **Proxy Endpoint Rerouting**: The Ollama upstream proxy route in `vite.config.ts` and
`api/ollama-chat.js` can be adjusted to point to alternative LLM hosts or local server instances.
- **Configurable Environment Parameters**:
- `VITE_APPWRITE_ENDPOINT` Base URL for the Appwrite API server.
- `VITE_APPWRITE_PROJECT_ID` The Appwrite project instance identifier.
- `VITE_APPWRITE_DATABASE_ID` TablesDB target database identifier.
- `VITE_APPWRITE_COLLECTION_ID` Table ID containing intake documents.
- `VITE_APPWRITE_CHAT_COLLECTION_ID` Table ID storing coach chatbot threads.
- `OLLAMA_API_KEY` Administrative bearer authorization token for Ollama endpoints.
- `OLLAMA_MODEL` Upstream model identifier (default: `deepseek-v4-pro:cloud`).
## Further Reading
- [Appwrite Platform Documentation](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/APPWRITE_SETUP.md) Detailed guide on database configuration, attributes, indexes, and row permissions.
- [Appwrite Admin Schema Migrations](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/scripts/setup-appwrite.mjs) Automated table creation and attributes loader script.
- > TODO: Put architecture decision records (ADRs) or technical whitepapers under a dedicated `/docs` directory.
+3
View File
@@ -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
+220
View File
@@ -0,0 +1,220 @@
# Project Overview
Red Bull Intake Tracker is a premium web-based tracking dashboard designed for tracking caffeine
and beverage consumption, with a strong focus on Red Bull products. Built using React, Vite, and
TypeScript, it allows users to record intake (amount, flavour, size, price, timestamp, and location),
monitor daily spending and caffeine limits, view structured trends and streaks, import/export
data via styled Excel or JSON formats, and engage in real-time encrypted-compatible dialogue with an
AI wellness coach powered by a serverless Ollama proxy endpoint. Synchronized dynamically with
Appwrite Cloud databases using secure row-level document permissions, it delivers a highly reactive,
personalized, and privacy-first self-tracking experience.
## Repository Structure
- `api/` Contains serverless backend handlers, including the API gateway proxy for Ollama chat endpoints.
- `dist/` Contains static HTML, JavaScript, and CSS bundle files output by the production build process.
- `node_modules/` Stores third-party library dependencies and packages managed by npm.
- `scripts/` Houses automation scripts, including database schema configuration tools for Appwrite.
- `src/` Contains client-side React source code, components, utility models, and stylesheets.
- `src/components/` Reusable UI panel elements, forms, and splash screen wrappers.
- `src/data/` Static configurations, including theme lists and built-in flavours mapping.
- `src/lib/` Business logic engines for calculations, file parsers, and Appwrite client connections.
## Build & Development Commands
Use the following shell-ready commands to install dependencies, run the application, lint the code,
and manage the cloud database.
### Dependency Installation
```bash
npm install
```
### Local Development Server
Starts a local development server at `http://localhost:5173`.
```bash
npm run dev
```
### Production Build & Bundling
Performs TypeScript diagnostic type-checks and compiles the application into the `dist/` directory.
```bash
npm run build
```
### Production Preview
Runs a local web server to preview the built production bundle.
```bash
npm run preview
```
### Code Linting
Runs ESLint over TypeScript files to identify syntax issues and code style warnings.
```bash
npm run lint
```
### Appwrite Cloud Database Setup
Automatically provisions the databases, tables, columns, and indexes on the configured Appwrite instance.
```bash
npm run setup:appwrite
```
### Automated Testing
> TODO: Add automated test suite command (e.g., `npm run test` using Vitest or Jest).
### Application Deployment
> TODO: Add production deployment pipeline command (e.g., Vercel, Netlify, or Docker deploy).
## Code Style & Conventions
- **Language**: TypeScript is strictly required for all UI components and logic scripts; JavaScript
is limited to serverless handlers and build scripting.
- **Strict Checks**: TypeScript's `strict` compiler option is enabled; avoid using `any` and ensure
all parameters and return values are explicitly typed.
- **Formatting**: Code should be formatted with 2-space indentation, trailing commas where supported,
and double quotes for JSX/TSX properties.
- **Linting**: Rules are governed by ESLint (`eslint.config.js`), extending the standard TypeScript
and React Hooks rulesets.
- **Naming Conventions**:
- React components and files use PascalCase (e.g., `CoachPanel.tsx`).
- Business logic, utilities, and helper hooks use camelCase (e.g., `appwriteEntries.ts`, `useCoachSession.ts`).
- Constants and static metadata arrays use UPPER_SNAKE_CASE (e.g., `BUILT_IN_FLAVOURS`).
- **Imports**: Prefer explicit ES module imports (`import { ... } from "..."`). Avoid wildcards.
- **Commit Messages**:
- > TODO: Define commit message guidelines and templates (e.g., Conventional Commits).
## Architecture Notes
### Flow Architecture Diagram
```mermaid
graph TD
subgraph Frontend [Vite React Client]
App[App.tsx - Core State & Shell]
Components[Onboarding, CoachPanel, Limits, etc.]
LibMetrics[metrics.ts & userLimits.ts]
LibIO[excel.ts & storage.ts]
LibTheme[themeTokens.ts & themes.ts]
HookCoach[useCoachSession.ts]
end
subgraph BackendAPI [API & Proxies]
ViteProxy[Vite Dev Server Middleware]
VercelHandler[api/ollama-chat.js Serverless Function]
end
subgraph External [External Services]
AppwriteCloud[Appwrite Cloud / TablesDB]
OllamaAPI[Ollama Cloud API]
end
App --> Components
App --> LibMetrics
App --> LibIO
App --> LibTheme
App --> HookCoach
HookCoach -- "/api/ollama-chat (Local)" --> ViteProxy
HookCoach -- "/api/ollama-chat (Prod)" --> VercelHandler
ViteProxy -- "Headers Auth" --> OllamaAPI
VercelHandler -- "Headers Auth" --> OllamaAPI
App --> AppwriteCloud
```
### Component Roles & Data Flow
1. **State Orchestration (`src/App.tsx`)**:
Acts as the monolithic hub of the frontend. It manages user authentication states, currently
selected views, active database operations, modals, onboarding triggers, and theme settings.
2. **Metrics & Limits (`src/lib/metrics.ts`, `src/lib/userLimits.ts`)**:
Process raw intakes to extract total cans, spendings, caffeine absorption, hydration estimates,
streaks, and coordinate warnings when user limits are breached or bedtime approaches.
3. **External Data Codecs (`src/lib/excel.ts`, `src/lib/storage.ts`)**:
Implement styled spreadsheet formatting with `exceljs`, data sanity validation, duplicate-aware
import preview engines, and local JSON backup/restore modules.
4. **Dynamic Styling (`src/lib/themeTokens.ts`, `src/data/themes.ts`)**:
Computes CSS tokens dynamically from a selection of Vocaloid or beverage-themed configurations,
writing variables into the document root for real-time visual styling modifications.
5. **Cloud Synced State (`src/lib/appwrite.ts`, `src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`)**:
Establishes client tunnels to Appwrite's serverless TablesDB backend, running row-secured
CRUD actions bound strictly to the current `userId`.
6. **AI Coach Chatbot (`src/lib/useCoachSession.ts`, `api/ollama-chat.js`)**:
A state-machine custom hook that pipelines user questions, injects historical intake aggregates,
and streams responses from DeepSeek through server-side Ollama proxy tunnels.
## Testing Strategy
The repository does not currently feature automated test files. Testing is executed manually.
### Unit & Integration Testing
- > TODO: Configure unit and integration tests (e.g., Vitest + React Testing Library) to validate
metrics computations, limits checks, and file importing codecs.
### End-to-End (E2E) Testing
- > TODO: Introduce E2E test suites (e.g., Playwright or Cypress) to cover authentication paths,
entry additions, theme switches, and chatbot conversation loops.
### Continuous Integration (CI)
- > TODO: Establish a GitHub Actions workflow pipeline to run linters, type checks, and tests on
every branch commit or pull request.
## Security & Compliance
- **Authentication**: Delegated entirely to Appwrite's built-in OAuth/Email-password protocols.
No user passwords or direct login credentials are saved inside the application state.
- **Client Security**: Client-side application calls only the Appwrite browser SDK. No administrative
or server-level API keys are ever shipped or exposed to the client.
- **Database Row Security**: All Appwrite tables have `Row Security` enabled. Users are granted
`create` permissions on the table level, but read, update, and delete actions require explicit
document permissions matching the user's specific ID (`user:{userId}`).
- **LLM API Security**: To avoid key exposure, the client connects to the proxy path `/api/ollama-chat`.
The secret `OLLAMA_API_KEY` is maintained exclusively in secure server-side environment variables.
- **Dependency Auditing**:
- > TODO: Add automated dependency checking (e.g., `npm audit` or Dependabot) in the CI pipeline.
- **Software Licensing**:
- > TODO: Add a standard LICENSE file (e.g., MIT, Apache-2.0) to explicitly detail terms of reuse.
## Agent Guardrails
- **Environment File Preservation**: Never edit, modify, or commit variables directly inside
`.env.local` or `.env` templates unless explicitly instructed by the user.
- **Credential Safety**: Never add, write, or hardcode API keys, access secrets, project keys, or
personal tokens into the codebase or configuration files.
- **Safe Directory Boundaries**: Do not add, write, or alter files inside administrative, system, or
auto-generated directories like `.git`, `.gemini`, `dist`, or `node_modules`.
- **Monolithic State Warnings**: `src/App.tsx` contains the core layout and view engine. Exercise
extreme care when making adjustments to prevent breaking the view transitions, auth hooks, or modals.
- **Database Alignment Rules**: Always verify that any changes to DB record structures or types are
mirrored across `src/types.ts`, Appwrite modules (`src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`),
and the migration runner `scripts/setup-appwrite.mjs`.
## Extensibility Hooks
- **Flavour Extensions**: New built-in Red Bull flavours, accent colors, and sugar-free rules can be
easily appended to the `BUILT_IN_FLAVOURS` array in `src/data/flavours.ts`.
- **UI Custom Themes**: Additional visual themes, including Vocaloid, seasonal, or custom branding,
can be integrated by adding definitions to the `APP_THEMES` array in `src/data/themes.ts`.
- **Proxy Endpoint Rerouting**: The Ollama upstream proxy route in `vite.config.ts` and
`api/ollama-chat.js` can be adjusted to point to alternative LLM hosts or local server instances.
- **Configurable Environment Parameters**:
- `VITE_APPWRITE_ENDPOINT` Base URL for the Appwrite API server.
- `VITE_APPWRITE_PROJECT_ID` The Appwrite project instance identifier.
- `VITE_APPWRITE_DATABASE_ID` TablesDB target database identifier.
- `VITE_APPWRITE_COLLECTION_ID` Table ID containing intake documents.
- `VITE_APPWRITE_CHAT_COLLECTION_ID` Table ID storing coach chatbot threads.
- `OLLAMA_API_KEY` Administrative bearer authorization token for Ollama endpoints.
- `OLLAMA_MODEL` Upstream model identifier (default: `deepseek-v4-pro:cloud`).
## Further Reading
- [Appwrite Platform Documentation](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/APPWRITE_SETUP.md) Detailed guide on database configuration, attributes, indexes, and row permissions.
- [Appwrite Admin Schema Migrations](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/scripts/setup-appwrite.mjs) Automated table creation and attributes loader script.
- > TODO: Put architecture decision records (ADRs) or technical whitepapers under a dedicated `/docs` directory.
+46
View File
@@ -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",
+1
View File
@@ -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",
+70
View File
@@ -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) {
+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;