feat: integrate barcode scanning functionality and create AGENTS.MD #2
@@ -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.
|
||||||
@@ -37,6 +37,7 @@ Configured defaults:
|
|||||||
- Database ID: `redbull_tracker`
|
- Database ID: `redbull_tracker`
|
||||||
- Collection ID: `intake_entries`
|
- Collection ID: `intake_entries`
|
||||||
- Chat collection ID: `coach_chats`
|
- 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`.
|
`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 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:
|
Create a database with ID:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -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.
|
||||||
Generated
+46
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@zxing/browser": "^0.2.0",
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
@@ -1898,6 +1899,41 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -5079,6 +5115,16 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@zxing/browser": "^0.2.0",
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* global console, fetch, process, setTimeout */
|
/* global console, fetch, process, setTimeout */
|
||||||
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { URL } from "node:url";
|
||||||
|
|
||||||
const env = loadEnvFiles([".env", ".env.local"]);
|
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 databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
||||||
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
||||||
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats");
|
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 apiKey = readEnv("APPWRITE_API_KEY", "");
|
||||||
|
const verifiedBarcodeProducts = JSON.parse(
|
||||||
|
readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"),
|
||||||
|
);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_.");
|
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",
|
"version",
|
||||||
]);
|
]);
|
||||||
await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]);
|
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.");
|
console.log("Appwrite database and tables ready.");
|
||||||
|
|
||||||
@@ -156,6 +189,43 @@ async function ensureIndex(tableId, index) {
|
|||||||
console.log(`Index ${tableId}.${index.key} created.`);
|
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) {
|
async function waitForColumns(tableId, keys) {
|
||||||
const pending = new Set(keys);
|
const pending = new Set(keys);
|
||||||
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
|
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
|
||||||
|
|||||||
+94
-52
@@ -3,6 +3,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Camera,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Cloud,
|
Cloud,
|
||||||
Command,
|
Command,
|
||||||
@@ -82,6 +83,7 @@ import {
|
|||||||
updateEntry,
|
updateEntry,
|
||||||
} from "./lib/appwriteEntries";
|
} from "./lib/appwriteEntries";
|
||||||
import { CoachPanel } from "./components/CoachPanel";
|
import { CoachPanel } from "./components/CoachPanel";
|
||||||
|
import { BarcodeScannerModal } from "./components/BarcodeScannerModal";
|
||||||
import { DailyLimitsCard } from "./components/DailyLimitsCard";
|
import { DailyLimitsCard } from "./components/DailyLimitsCard";
|
||||||
import { LimitsSettingsForm } from "./components/LimitsSettingsForm";
|
import { LimitsSettingsForm } from "./components/LimitsSettingsForm";
|
||||||
import { OnboardingScreen } from "./components/OnboardingScreen";
|
import { OnboardingScreen } from "./components/OnboardingScreen";
|
||||||
@@ -191,7 +193,9 @@ function App() {
|
|||||||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||||
const [activeView, setActiveView] = useState<AppView>("overview");
|
const [activeView, setActiveView] = useState<AppView>("overview");
|
||||||
const [isEntryModalOpen, setIsEntryModalOpen] = useState(false);
|
const [isEntryModalOpen, setIsEntryModalOpen] = useState(false);
|
||||||
|
const [entryInitialDraft, setEntryInitialDraft] = useState<EntryDraft | null>(null);
|
||||||
const [editingEntry, setEditingEntry] = useState<RedBullEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<RedBullEntry | null>(null);
|
||||||
|
const [isBarcodeScannerOpen, setIsBarcodeScannerOpen] = useState(false);
|
||||||
const [isResetOpen, setIsResetOpen] = useState(false);
|
const [isResetOpen, setIsResetOpen] = useState(false);
|
||||||
const [notice, setNotice] = useState("Appwrite session pending.");
|
const [notice, setNotice] = useState("Appwrite session pending.");
|
||||||
const [dataLoading, setDataLoading] = useState(false);
|
const [dataLoading, setDataLoading] = useState(false);
|
||||||
@@ -390,9 +394,14 @@ function App() {
|
|||||||
|
|
||||||
function openNewEntry() {
|
function openNewEntry() {
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
|
setEntryInitialDraft(null);
|
||||||
setIsEntryModalOpen(true);
|
setIsEntryModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBarcodeScanner() {
|
||||||
|
setIsBarcodeScannerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveUserLimits(next: UserLimits) {
|
async function saveUserLimits(next: UserLimits) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setActionLoading("save-limits");
|
setActionLoading("save-limits");
|
||||||
@@ -451,6 +460,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
|
setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
|
setEntryInitialDraft(null);
|
||||||
setIsEntryModalOpen(false);
|
setIsEntryModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDataError(appwriteErrorMessage(error));
|
setDataError(appwriteErrorMessage(error));
|
||||||
@@ -478,6 +488,18 @@ function App() {
|
|||||||
requestEntrySave(draft, editingEntry?.id);
|
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]) {
|
async function quickAdd(item: (typeof QUICK_ADDS)[number]) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const meta = flavourMeta(item.flavour);
|
const meta = flavourMeta(item.flavour);
|
||||||
@@ -682,6 +704,7 @@ function App() {
|
|||||||
setupStatus={setupStatus}
|
setupStatus={setupStatus}
|
||||||
user={user}
|
user={user}
|
||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
|
onScan={openBarcodeScanner}
|
||||||
onChange={setActiveView}
|
onChange={setActiveView}
|
||||||
onOpenSettings={() => setActiveView("settings")}
|
onOpenSettings={() => setActiveView("settings")}
|
||||||
/>
|
/>
|
||||||
@@ -693,6 +716,7 @@ function App() {
|
|||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
actionLoading={actionLoading}
|
actionLoading={actionLoading}
|
||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
|
onScan={openBarcodeScanner}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusRail actionLoading={actionLoading} dataError={dataError} setupStatus={setupStatus} />
|
<StatusRail actionLoading={actionLoading} dataError={dataError} setupStatus={setupStatus} />
|
||||||
@@ -721,6 +745,7 @@ function App() {
|
|||||||
coachSession={coachSession}
|
coachSession={coachSession}
|
||||||
onQuickAdd={(item) => void quickAdd(item)}
|
onQuickAdd={(item) => void quickAdd(item)}
|
||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
|
onScan={openBarcodeScanner}
|
||||||
onOpenCoach={(prompt) => {
|
onOpenCoach={(prompt) => {
|
||||||
if (prompt) coachSession.queuePrompt(prompt);
|
if (prompt) coachSession.queuePrompt(prompt);
|
||||||
setActiveView("coach");
|
setActiveView("coach");
|
||||||
@@ -801,6 +826,7 @@ function App() {
|
|||||||
|
|
||||||
<EntryModal
|
<EntryModal
|
||||||
entry={editingEntry}
|
entry={editingEntry}
|
||||||
|
initialDraft={entryInitialDraft}
|
||||||
flavours={allFlavours}
|
flavours={allFlavours}
|
||||||
open={isEntryModalOpen}
|
open={isEntryModalOpen}
|
||||||
saving={actionLoading === "save-entry"}
|
saving={actionLoading === "save-entry"}
|
||||||
@@ -809,10 +835,21 @@ function App() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsEntryModalOpen(false);
|
setIsEntryModalOpen(false);
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
|
setEntryInitialDraft(null);
|
||||||
}}
|
}}
|
||||||
onSave={(draft) => void saveEntry(draft)}
|
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
|
<ImportPreviewModal
|
||||||
busy={actionLoading === "confirm-excel-import"}
|
busy={actionLoading === "confirm-excel-import"}
|
||||||
preview={importPreview}
|
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({
|
function ThemePicker({
|
||||||
themeId,
|
themeId,
|
||||||
@@ -1091,6 +1104,7 @@ function Sidebar({
|
|||||||
setupStatus,
|
setupStatus,
|
||||||
user,
|
user,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onScan,
|
||||||
onChange,
|
onChange,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: {
|
}: {
|
||||||
@@ -1100,6 +1114,7 @@ function Sidebar({
|
|||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
onChange: (view: AppView) => void;
|
onChange: (view: AppView) => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -1120,6 +1135,11 @@ function Sidebar({
|
|||||||
Add intake
|
Add intake
|
||||||
</button>
|
</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 className="drawer-nav" aria-label="Main navigation">
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<button
|
<button
|
||||||
@@ -1175,10 +1195,12 @@ function TopBar({
|
|||||||
activeView,
|
activeView,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onScan,
|
||||||
}: {
|
}: {
|
||||||
activeView: AppView;
|
activeView: AppView;
|
||||||
actionLoading: string | null;
|
actionLoading: string | null;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
}) {
|
}) {
|
||||||
const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0];
|
const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0];
|
||||||
const title = activeItem.label;
|
const title = activeItem.label;
|
||||||
@@ -1204,6 +1226,10 @@ function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="top-action-row">
|
<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)}>
|
<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" />
|
<Plus size={18} aria-hidden="true" />
|
||||||
Add Intake
|
Add Intake
|
||||||
@@ -1261,6 +1287,7 @@ function OverviewView({
|
|||||||
limitCheck,
|
limitCheck,
|
||||||
onQuickAdd,
|
onQuickAdd,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onScan,
|
||||||
onOpenCoach,
|
onOpenCoach,
|
||||||
onOpenLogbook,
|
onOpenLogbook,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
@@ -1278,6 +1305,7 @@ function OverviewView({
|
|||||||
coachSession: CoachSession;
|
coachSession: CoachSession;
|
||||||
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
|
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
onOpenCoach: (prompt?: string) => void;
|
onOpenCoach: (prompt?: string) => void;
|
||||||
onOpenLogbook: () => void;
|
onOpenLogbook: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
@@ -1305,7 +1333,7 @@ function OverviewView({
|
|||||||
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
||||||
</section>
|
</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 ? (
|
{limitCheck.violations.length ? (
|
||||||
<section className="glass-panel border border-amber-200/20 bg-amber-200/10 p-4 sm:p-5">
|
<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,
|
userLimits,
|
||||||
limitCheck,
|
limitCheck,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onScan,
|
||||||
}: {
|
}: {
|
||||||
dashboard: Dashboard;
|
dashboard: Dashboard;
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
userLimits: UserLimits;
|
userLimits: UserLimits;
|
||||||
limitCheck: LimitCheckResult;
|
limitCheck: LimitCheckResult;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
}) {
|
}) {
|
||||||
const limitSummary = [
|
const limitSummary = [
|
||||||
userLimits.dailyCanLimit != null ? `${limitCheck.todayCans.toFixed(1)}/${userLimits.dailyCanLimit} cans` : null,
|
userLimits.dailyCanLimit != null ? `${limitCheck.todayCans.toFixed(1)}/${userLimits.dailyCanLimit} cans` : null,
|
||||||
@@ -1542,6 +1572,10 @@ function TodayPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="today-action-row mt-6 hidden flex-wrap items-center gap-2 lg:flex">
|
<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}>
|
<button className="primary-button" type="button" onClick={onAdd}>
|
||||||
<Plus size={18} aria-hidden="true" />
|
<Plus size={18} aria-hidden="true" />
|
||||||
Add intake
|
Add intake
|
||||||
@@ -1761,11 +1795,13 @@ function SpendingPredictionsCard({
|
|||||||
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||||
)[0].dateTime
|
)[0].dateTime
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [entries]);
|
}, [entries]);
|
||||||
|
|
||||||
const trackingDays = useMemo(() => {
|
const trackingDays = useMemo(() => {
|
||||||
const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime());
|
const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime());
|
||||||
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [firstEntryDate]);
|
}, [firstEntryDate]);
|
||||||
|
|
||||||
const activePeriodDays = Math.min(30, trackingDays);
|
const activePeriodDays = Math.min(30, trackingDays);
|
||||||
@@ -1781,12 +1817,13 @@ function SpendingPredictionsCard({
|
|||||||
avgDailyCans: totalCans / activePeriodDays,
|
avgDailyCans: totalCans / activePeriodDays,
|
||||||
hasData: entries.length > 0,
|
hasData: entries.length > 0,
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [entries, activePeriodDays]);
|
}, [entries, activePeriodDays]);
|
||||||
|
|
||||||
const projectionData = useMemo(() => {
|
const projectionData = useMemo(() => {
|
||||||
return Array.from({ length: projectionDays }).map((_, index) => {
|
return Array.from({ length: projectionDays }).map((_, index) => {
|
||||||
const day = index + 1;
|
const day = index + 1;
|
||||||
const dataPoint: any = {
|
const dataPoint: Record<string, string | number> = {
|
||||||
label: `Day ${day}`,
|
label: `Day ${day}`,
|
||||||
"Current Path": Number((day * stats.avgDailySpend).toFixed(2)),
|
"Current Path": Number((day * stats.avgDailySpend).toFixed(2)),
|
||||||
"Optimal Path (-20%)": Number((day * stats.avgDailySpend * 0.8).toFixed(2)),
|
"Optimal Path (-20%)": Number((day * stats.avgDailySpend * 0.8).toFixed(2)),
|
||||||
@@ -2032,7 +2069,7 @@ function SettingsView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
<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" />}
|
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
|
||||||
Sync now
|
Sync now
|
||||||
</button>
|
</button>
|
||||||
@@ -2415,6 +2452,7 @@ function DisclaimerCard() {
|
|||||||
function EntryModal({
|
function EntryModal({
|
||||||
open,
|
open,
|
||||||
entry,
|
entry,
|
||||||
|
initialDraft,
|
||||||
flavours,
|
flavours,
|
||||||
saving,
|
saving,
|
||||||
userLimits,
|
userLimits,
|
||||||
@@ -2424,6 +2462,7 @@ function EntryModal({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
entry: RedBullEntry | null;
|
entry: RedBullEntry | null;
|
||||||
|
initialDraft: EntryDraft | null;
|
||||||
flavours: Flavour[];
|
flavours: Flavour[];
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
userLimits: UserLimits;
|
userLimits: UserLimits;
|
||||||
@@ -2432,37 +2471,39 @@ function EntryModal({
|
|||||||
onSave: (draft: EntryDraft) => void;
|
onSave: (draft: EntryDraft) => void;
|
||||||
}) {
|
}) {
|
||||||
const firstFieldRef = useRef<HTMLInputElement>(null);
|
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 [selectedFlavour, setSelectedFlavour] = useState(initialFlavour);
|
||||||
const [customFlavour, setCustomFlavour] = useState("");
|
const [customFlavour, setCustomFlavour] = useState("");
|
||||||
const [customAccent, setCustomAccent] = useState(MATERIAL_ACCENTS.custom);
|
const [customAccent, setCustomAccent] = useState(MATERIAL_ACCENTS.custom);
|
||||||
const [cans, setCans] = useState(entry?.cans.toString() ?? "1");
|
const [cans, setCans] = useState(activeDraft?.cans.toString() ?? "1");
|
||||||
const [sizePreset, setSizePreset] = useState(sizeToPreset(entry?.sizeMl ?? 250));
|
const [sizePreset, setSizePreset] = useState(sizeToPreset(activeDraft?.sizeMl ?? 250));
|
||||||
const [customSize, setCustomSize] = useState(entry?.sizeMl.toString() ?? "250");
|
const [customSize, setCustomSize] = useState(activeDraft?.sizeMl.toString() ?? "250");
|
||||||
const [pricePerCan, setPricePerCan] = useState(entry?.pricePerCan.toString() ?? "1.75");
|
const [pricePerCan, setPricePerCan] = useState(activeDraft?.pricePerCan.toString() ?? "1.75");
|
||||||
const [dateTime, setDateTime] = useState(formatLocalInput(entry ? new Date(entry.dateTime) : new Date()));
|
const [dateTime, setDateTime] = useState(formatLocalInput(activeDraft ? new Date(activeDraft.dateTime) : new Date()));
|
||||||
const [store, setStore] = useState(entry?.store ?? "");
|
const [store, setStore] = useState(activeDraft?.store ?? "");
|
||||||
const [notes, setNotes] = useState(entry?.notes ?? "");
|
const [notes, setNotes] = useState(activeDraft?.notes ?? "");
|
||||||
const [sugarFree, setSugarFree] = useState(entry?.sugarFree ?? false);
|
const [sugarFree, setSugarFree] = useState(activeDraft?.sugarFree ?? false);
|
||||||
const [caffeineOverride, setCaffeineOverride] = useState(entry?.caffeineMgPerCan?.toString() ?? "");
|
const [caffeineOverride, setCaffeineOverride] = useState(activeDraft?.caffeineMgPerCan?.toString() ?? "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const editingCustom = entry && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === entry.flavour);
|
const draft = entry ?? initialDraft;
|
||||||
setSelectedFlavour(editingCustom ? entry.flavour : entry?.flavour ?? DEFAULT_FLAVOUR.name);
|
const editingCustom = draft && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === draft.flavour);
|
||||||
setCustomFlavour(editingCustom ? entry.flavour : "");
|
setSelectedFlavour(editingCustom ? draft.flavour : draft?.flavour ?? DEFAULT_FLAVOUR.name);
|
||||||
setCustomAccent(entry?.flavourAccent ?? MATERIAL_ACCENTS.custom);
|
setCustomFlavour(editingCustom ? draft.flavour : "");
|
||||||
setCans(entry?.cans.toString() ?? "1");
|
setCustomAccent(draft?.flavourAccent ?? MATERIAL_ACCENTS.custom);
|
||||||
setSizePreset(sizeToPreset(entry?.sizeMl ?? 250));
|
setCans(draft?.cans.toString() ?? "1");
|
||||||
setCustomSize(entry?.sizeMl.toString() ?? "250");
|
setSizePreset(sizeToPreset(draft?.sizeMl ?? 250));
|
||||||
setPricePerCan(entry?.pricePerCan.toString() ?? defaultPriceForSize(250).toString());
|
setCustomSize(draft?.sizeMl.toString() ?? "250");
|
||||||
setDateTime(formatLocalInput(entry ? new Date(entry.dateTime) : new Date()));
|
setPricePerCan(draft?.pricePerCan.toString() ?? defaultPriceForSize(250).toFixed(2));
|
||||||
setStore(entry?.store ?? "");
|
setDateTime(formatLocalInput(draft ? new Date(draft.dateTime) : new Date()));
|
||||||
setNotes(entry?.notes ?? "");
|
setStore(draft?.store ?? "");
|
||||||
setSugarFree(entry?.sugarFree ?? false);
|
setNotes(draft?.notes ?? "");
|
||||||
setCaffeineOverride(entry?.caffeineMgPerCan?.toString() ?? "");
|
setSugarFree(draft?.sugarFree ?? false);
|
||||||
|
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
|
||||||
window.setTimeout(() => firstFieldRef.current?.focus(), 80);
|
window.setTimeout(() => firstFieldRef.current?.focus(), 80);
|
||||||
}, [entry, open]);
|
}, [entry, initialDraft, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -2503,7 +2544,7 @@ function EntryModal({
|
|||||||
store: store.trim(),
|
store: store.trim(),
|
||||||
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
||||||
caffeineMgPerCan: override,
|
caffeineMgPerCan: override,
|
||||||
source: entry?.source ?? "manual",
|
source: entry?.source ?? initialDraft?.source ?? "manual",
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
@@ -2521,6 +2562,7 @@ function EntryModal({
|
|||||||
sizePreset,
|
sizePreset,
|
||||||
caffeineOverride,
|
caffeineOverride,
|
||||||
entry?.source,
|
entry?.source,
|
||||||
|
initialDraft?.source,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const draftLimitCheck = useMemo(() => {
|
const draftLimitCheck = useMemo(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()];
|
||||||
|
}
|
||||||
@@ -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
@@ -1,20 +1,25 @@
|
|||||||
import type { Flavour } from "../types";
|
import type { Flavour } from "../types";
|
||||||
|
|
||||||
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
||||||
{ name: "Original", accent: "#00A7FF" },
|
{ name: "Original", accent: "#282874" },
|
||||||
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
|
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
|
||||||
{ name: "Ruby", accent: "#C3093B" },
|
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
|
||||||
{ name: "Iced Vanilla", accent: "#49adbe" },
|
{ name: "Ruby", accent: "#B50045" },
|
||||||
{ name: "Tropical", accent: "#FFC247" },
|
{ name: "Iced Vanilla", accent: "#53B2C2" },
|
||||||
{ name: "Watermelon", accent: "#FF355E" },
|
{ 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: "Blueberry", accent: "#496DFF" },
|
||||||
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
{ name: "Coconut Berry", accent: "#0070B8" },
|
||||||
{ name: "Peach", accent: "#FF9B63" },
|
{ name: "Peach", accent: "#E24585" },
|
||||||
{ name: "Juneberry", accent: "#9C73FF" },
|
{ name: "Juneberry", accent: "#0085C8" },
|
||||||
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||||
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
{ name: "Curuba Elderflower", accent: "#78B941" },
|
||||||
{ name: "Winter Edition", accent: "#7CE7FF" },
|
{ name: "Winter Edition", accent: "#BF1431" },
|
||||||
{ name: "Summer Edition", accent: "#f0e53b" },
|
{ name: "Summer Edition", accent: "#F2E853" },
|
||||||
{ name: "Other", accent: "#AEB9C7" },
|
{ name: "Other", accent: "#AEB9C7" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+101
-99
@@ -51,147 +51,149 @@ export const APP_THEMES: AppTheme[] = [
|
|||||||
tertiary: "#ffd8e7",
|
tertiary: "#ffd8e7",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
theme("original", "Original", "flavour", "#00a7ff", {
|
theme("original", "Original", "flavour", "#282874", {
|
||||||
primary: "#0077c8",
|
primary: "#282874",
|
||||||
secondary: "#00a7ff",
|
secondary: "#efefef",
|
||||||
tertiary: "#1e3264",
|
tertiary: "#d4af37",
|
||||||
|
tokens: {
|
||||||
|
chartSecondary: "#e6301f",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
theme("zero", "Zero", "flavour", "#2a2a2a", {
|
theme("zero", "Zero", "flavour", "#b1d0ee", {
|
||||||
primary: "#2a2a2a",
|
primary: "#b1d0ee",
|
||||||
secondary: "#5c5c5c",
|
secondary: "#efefef",
|
||||||
tertiary: "#8a8a8a",
|
tertiary: "#e6301f",
|
||||||
dark: true,
|
|
||||||
}),
|
}),
|
||||||
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
|
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
|
||||||
primary: "#d4c400",
|
primary: "#f2e853",
|
||||||
secondary: "#f0e53b",
|
secondary: "#efefef",
|
||||||
tertiary: "#ffc247",
|
tertiary: "#8a8f98",
|
||||||
}),
|
}),
|
||||||
theme("cherry", "Cherry Edition", "flavour", "#e40046", {
|
theme("cherry", "Cherry Edition", "flavour", "#d81b60", {
|
||||||
primary: "#c3093b",
|
primary: "#d81b60",
|
||||||
secondary: "#e40046",
|
secondary: "#efefef",
|
||||||
tertiary: "#ff6b8a",
|
tertiary: "#b50045",
|
||||||
}),
|
}),
|
||||||
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
|
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
|
||||||
primary: "#e85d8a",
|
primary: "#e85d8a",
|
||||||
secondary: "#ffb3c6",
|
secondary: "#ffb3c6",
|
||||||
tertiary: "#ffd8e7",
|
tertiary: "#ffd8e7",
|
||||||
}),
|
}),
|
||||||
theme("apple", "Apple Edition", "flavour", "#78be20", {
|
theme("apple", "Apple Edition", "flavour", "#bf1431", {
|
||||||
primary: "#5a9a12",
|
primary: "#bf1431",
|
||||||
secondary: "#78be20",
|
secondary: "#f6c300",
|
||||||
tertiary: "#a8d84a",
|
tertiary: "#f3911b",
|
||||||
}),
|
}),
|
||||||
theme("peach", "Peach Edition", "flavour", "#ff9b63", {
|
theme("peach", "Peach Edition", "flavour", "#e24585", {
|
||||||
primary: "#e87a3a",
|
primary: "#e24585",
|
||||||
secondary: "#ff9b63",
|
secondary: "#efefef",
|
||||||
tertiary: "#ffc9a3",
|
tertiary: "#d6417e",
|
||||||
}),
|
}),
|
||||||
theme("ice", "Ice Edition", "flavour", "#49adbe", {
|
theme("ice", "Ice Edition", "flavour", "#49adbe", {
|
||||||
primary: "#2d8a9a",
|
primary: "#53b2c2",
|
||||||
secondary: "#49adbe",
|
secondary: "#efefef",
|
||||||
tertiary: "#7ce7ff",
|
tertiary: "#49adbe",
|
||||||
}),
|
}),
|
||||||
theme("blue-edition", "Blue Edition", "flavour", "#496dff", {
|
theme("blue-edition", "Blue Edition", "flavour", "#0085c8", {
|
||||||
primary: "#3a52cc",
|
primary: "#0085c8",
|
||||||
secondary: "#496dff",
|
secondary: "#efefef",
|
||||||
tertiary: "#9c73ff",
|
tertiary: "#ff73d1",
|
||||||
}),
|
}),
|
||||||
theme("red-edition", "Red Edition", "flavour", "#ff355e", {
|
theme("red-edition", "Red Edition", "flavour", "#e6301f", {
|
||||||
primary: "#e02045",
|
primary: "#e6301f",
|
||||||
secondary: "#ff355e",
|
secondary: "#efefef",
|
||||||
tertiary: "#ff6b8a",
|
tertiary: "#78b941",
|
||||||
}),
|
}),
|
||||||
theme("tropical", "Tropical Edition", "flavour", "#ffc247", {
|
theme("tropical", "Tropical Edition", "flavour", "#ffcb04", {
|
||||||
primary: "#e0a820",
|
primary: "#ffcb04",
|
||||||
secondary: "#ffc247",
|
secondary: "#efefef",
|
||||||
tertiary: "#ff9b63",
|
tertiary: "#f6c300",
|
||||||
}),
|
}),
|
||||||
theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", {
|
theme("coconut", "Coconut Edition", "flavour", "#0070b8", {
|
||||||
primary: "#4ec4e0",
|
primary: "#0070b8",
|
||||||
secondary: "#7ce7ff",
|
secondary: "#efefef",
|
||||||
tertiary: "#d8f9ff",
|
tertiary: "#8a8f98",
|
||||||
}),
|
}),
|
||||||
theme("green-edition", "Green Edition", "flavour", "#b7ff4a", {
|
theme("green-edition", "Green Edition", "flavour", "#78b941", {
|
||||||
primary: "#7acc20",
|
primary: "#78b941",
|
||||||
secondary: "#b7ff4a",
|
secondary: "#efefef",
|
||||||
tertiary: "#d4ff8a",
|
tertiary: "#f3911b",
|
||||||
}),
|
}),
|
||||||
theme("apricot", "Apricot Edition", "flavour", "#ff8c42", {
|
theme("apricot", "Apricot Edition", "flavour", "#f3911b", {
|
||||||
primary: "#e06a20",
|
primary: "#f3911b",
|
||||||
secondary: "#ff8c42",
|
secondary: "#efefef",
|
||||||
tertiary: "#ffb87a",
|
tertiary: "#d6417e",
|
||||||
}),
|
}),
|
||||||
theme("ruby", "Ruby Edition", "flavour", "#c3093b", {
|
theme("ruby", "Ruby Edition", "flavour", "#b50045", {
|
||||||
primary: "#a00730",
|
primary: "#b50045",
|
||||||
secondary: "#c3093b",
|
secondary: "#efefef",
|
||||||
tertiary: "#e04060",
|
tertiary: "#a3e635",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", {
|
theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", {
|
||||||
primary: "#8a9bb0",
|
primary: "#009edf",
|
||||||
secondary: "#c8d4e0",
|
secondary: "#efefef",
|
||||||
tertiary: "#e7eef8",
|
tertiary: "#e6301f",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", {
|
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", {
|
||||||
primary: "#c4c020",
|
primary: "#f2e853",
|
||||||
secondary: "#e8e4a0",
|
secondary: "#efefef",
|
||||||
tertiary: "#f0e53b",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", {
|
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", {
|
||||||
primary: "#6a9a30",
|
primary: "#bf1431",
|
||||||
secondary: "#b8d4a0",
|
secondary: "#f6c300",
|
||||||
tertiary: "#78be20",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", {
|
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", {
|
||||||
primary: "#d08050",
|
primary: "#e24585",
|
||||||
secondary: "#f0d0b8",
|
secondary: "#efefef",
|
||||||
tertiary: "#ff9b63",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", {
|
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", {
|
||||||
primary: "#4a9aaa",
|
primary: "#53b2c2",
|
||||||
secondary: "#b8e0e8",
|
secondary: "#efefef",
|
||||||
tertiary: "#49adbe",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", {
|
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", {
|
||||||
primary: "#9070c0",
|
primary: "#7d62ce",
|
||||||
secondary: "#d8c8f0",
|
secondary: "#44c7b7",
|
||||||
tertiary: "#b898e0",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", {
|
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", {
|
||||||
primary: "#d06090",
|
primary: "#e77bab",
|
||||||
secondary: "#f0c8d8",
|
secondary: "#8a1f3d",
|
||||||
tertiary: "#ffb7d9",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", {
|
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", {
|
||||||
primary: "#5060c0",
|
primary: "#0085c8",
|
||||||
secondary: "#c8d0f8",
|
secondary: "#efefef",
|
||||||
tertiary: "#496dff",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", {
|
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", {
|
||||||
primary: "#60b8d0",
|
primary: "#0070b8",
|
||||||
secondary: "#d0f0f8",
|
secondary: "#efefef",
|
||||||
tertiary: "#7ce7ff",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", {
|
theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", {
|
||||||
primary: "#70a830",
|
primary: "#78b941",
|
||||||
secondary: "#d8f0b8",
|
secondary: "#efefef",
|
||||||
tertiary: "#b7ff4a",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", {
|
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", {
|
||||||
primary: "#a03050",
|
primary: "#b50045",
|
||||||
secondary: "#f0c0c8",
|
secondary: "#efefef",
|
||||||
tertiary: "#c3093b",
|
tertiary: "#009edf",
|
||||||
sugarFree: true,
|
sugarFree: true,
|
||||||
}),
|
}),
|
||||||
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
|
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export const appwriteConfig = {
|
|||||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
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),
|
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||||
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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") };
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ export function useCoachSession(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user.$id]);
|
}, [user.$id]);
|
||||||
|
|
||||||
const upsertChatState = useCallback((chat: CoachChat) => {
|
const upsertChatState = useCallback((chat: CoachChat) => {
|
||||||
@@ -240,7 +241,7 @@ export function useCoachSession(
|
|||||||
setBusy(false);
|
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) => {
|
const queuePrompt = useCallback((prompt: string) => {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,57 @@ export type EntryDraft = Omit<
|
|||||||
source?: RedBullEntry["source"];
|
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 = {
|
export type Filters = {
|
||||||
flavour: string;
|
flavour: string;
|
||||||
dateRange: DateFilter;
|
dateRange: DateFilter;
|
||||||
|
|||||||
Vendored
+1
@@ -6,6 +6,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||||
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
||||||
readonly VITE_APPWRITE_CHAT_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_SUCCESS_URL?: string;
|
||||||
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||||
readonly VITE_OLLAMA_PROXY_URL?: string;
|
readonly VITE_OLLAMA_PROXY_URL?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user