Compare commits
2 Commits
f4a046ec9d
...
023ec1096f
| Author | SHA1 | Date | |
|---|---|---|---|
| 023ec1096f | |||
| a7993af1d2 |
@@ -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`
|
||||
- Collection ID: `intake_entries`
|
||||
- Chat collection ID: `coach_chats`
|
||||
- Barcode collection ID: `barcode_products`
|
||||
|
||||
`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
|
||||
|
||||
@@ -86,6 +87,8 @@ So if the Console asks you to create a **table**, that is the same resource as t
|
||||
|
||||
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
|
||||
|
||||
The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions.
|
||||
|
||||
Create a database with ID:
|
||||
|
||||
```text
|
||||
|
||||
@@ -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",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"appwrite": "^25.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
@@ -1898,6 +1899,41 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/browser": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz",
|
||||
"integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "^0.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@zxing/library": "^0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/library": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
|
||||
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ts-custom-error": "^3.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 24.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "~0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/text-encoding": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
||||
"license": "(Unlicense OR Apache-2.0)",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -5079,6 +5115,16 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-custom-error": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"appwrite": "^25.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* global console, fetch, process, setTimeout */
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { URL } from "node:url";
|
||||
|
||||
const env = loadEnvFiles([".env", ".env.local"]);
|
||||
|
||||
@@ -9,7 +10,11 @@ const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
|
||||
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
||||
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
||||
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats");
|
||||
const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products");
|
||||
const apiKey = readEnv("APPWRITE_API_KEY", "");
|
||||
const verifiedBarcodeProducts = JSON.parse(
|
||||
readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_.");
|
||||
@@ -59,6 +64,34 @@ await retireLegacyChatColumns(chatTableId, [
|
||||
"version",
|
||||
]);
|
||||
await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]);
|
||||
await ensureTable({
|
||||
tableId: barcodeTableId,
|
||||
name: "Barcode products",
|
||||
// Schema notes:
|
||||
// - scope="verified" rows are seeded by this admin script and readable by signed-in users.
|
||||
// - scope="user" rows are created by the browser SDK with per-user row permissions.
|
||||
columns: [
|
||||
{ kind: "string", key: "scope", size: 16, required: true },
|
||||
{ kind: "string", key: "ownerUserId", size: 64, required: false },
|
||||
{ kind: "string", key: "barcode", size: 32, required: true },
|
||||
{ kind: "string", key: "flavourName", size: 128, required: true },
|
||||
{ kind: "integer", key: "sizeMl", required: true },
|
||||
{ kind: "float", key: "pricePerCan", required: true },
|
||||
{ kind: "boolean", key: "sugarFree", required: true },
|
||||
{ kind: "float", key: "caffeineMgPerCan", required: false },
|
||||
{ kind: "string", key: "verifiedBy", size: 512, required: false },
|
||||
{ kind: "string", key: "sourceName", size: 512, required: false },
|
||||
{ kind: "string", key: "sourceUrl", size: 2048, required: false },
|
||||
{ kind: "string", key: "variant", size: 64, required: false },
|
||||
{ kind: "string", key: "notes", size: 2000, required: false },
|
||||
],
|
||||
indexes: [
|
||||
{ key: "barcode", type: "key", columns: ["barcode"], orders: ["ASC"], lengths: [32] },
|
||||
{ key: "scope_barcode", type: "key", columns: ["scope", "barcode"], orders: ["ASC", "ASC"], lengths: [16, 32] },
|
||||
{ key: "user_barcode", type: "key", columns: ["ownerUserId", "barcode"], orders: ["ASC", "ASC"], lengths: [64, 32] },
|
||||
],
|
||||
});
|
||||
await seedVerifiedBarcodeProducts(barcodeTableId, verifiedBarcodeProducts);
|
||||
|
||||
console.log("Appwrite database and tables ready.");
|
||||
|
||||
@@ -156,6 +189,43 @@ async function ensureIndex(tableId, index) {
|
||||
console.log(`Index ${tableId}.${index.key} created.`);
|
||||
}
|
||||
|
||||
async function seedVerifiedBarcodeProducts(tableId, products) {
|
||||
for (const [barcode, product] of Object.entries(products)) {
|
||||
const rowId = `verified_${barcode}`;
|
||||
const data = {
|
||||
scope: "verified",
|
||||
ownerUserId: "",
|
||||
barcode,
|
||||
flavourName: product.flavourName,
|
||||
sizeMl: product.sizeMl,
|
||||
pricePerCan: product.pricePerCan,
|
||||
sugarFree: Boolean(product.sugarFree),
|
||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
||||
verifiedBy: product.verifiedBy ?? "",
|
||||
sourceName: product.sourceName ?? "",
|
||||
sourceUrl: product.sourceUrl ?? "",
|
||||
variant: product.variant ?? "",
|
||||
notes: product.notes ?? "",
|
||||
};
|
||||
const path = `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`;
|
||||
const existing = await request("GET", path, undefined, [200, 404]);
|
||||
|
||||
if (existing.status === 404) {
|
||||
await request(
|
||||
"POST",
|
||||
`/tablesdb/${databaseId}/tables/${tableId}/rows`,
|
||||
{ rowId, data, permissions: ['read("users")'] },
|
||||
[201],
|
||||
);
|
||||
console.log(`Verified barcode ${barcode} seeded.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await request("PUT", path, { data, permissions: ['read("users")'] }, [200]);
|
||||
console.log(`Verified barcode ${barcode} updated.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForColumns(tableId, keys) {
|
||||
const pending = new Set(keys);
|
||||
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
|
||||
|
||||
+94
-52
@@ -3,6 +3,7 @@ import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Command,
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
updateEntry,
|
||||
} from "./lib/appwriteEntries";
|
||||
import { CoachPanel } from "./components/CoachPanel";
|
||||
import { BarcodeScannerModal } from "./components/BarcodeScannerModal";
|
||||
import { DailyLimitsCard } from "./components/DailyLimitsCard";
|
||||
import { LimitsSettingsForm } from "./components/LimitsSettingsForm";
|
||||
import { OnboardingScreen } from "./components/OnboardingScreen";
|
||||
@@ -191,7 +193,9 @@ function App() {
|
||||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||
const [activeView, setActiveView] = useState<AppView>("overview");
|
||||
const [isEntryModalOpen, setIsEntryModalOpen] = useState(false);
|
||||
const [entryInitialDraft, setEntryInitialDraft] = useState<EntryDraft | null>(null);
|
||||
const [editingEntry, setEditingEntry] = useState<RedBullEntry | null>(null);
|
||||
const [isBarcodeScannerOpen, setIsBarcodeScannerOpen] = useState(false);
|
||||
const [isResetOpen, setIsResetOpen] = useState(false);
|
||||
const [notice, setNotice] = useState("Appwrite session pending.");
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
@@ -390,9 +394,14 @@ function App() {
|
||||
|
||||
function openNewEntry() {
|
||||
setEditingEntry(null);
|
||||
setEntryInitialDraft(null);
|
||||
setIsEntryModalOpen(true);
|
||||
}
|
||||
|
||||
function openBarcodeScanner() {
|
||||
setIsBarcodeScannerOpen(true);
|
||||
}
|
||||
|
||||
async function saveUserLimits(next: UserLimits) {
|
||||
if (!user) return;
|
||||
setActionLoading("save-limits");
|
||||
@@ -451,6 +460,7 @@ function App() {
|
||||
);
|
||||
setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
|
||||
setEditingEntry(null);
|
||||
setEntryInitialDraft(null);
|
||||
setIsEntryModalOpen(false);
|
||||
} catch (error) {
|
||||
setDataError(appwriteErrorMessage(error));
|
||||
@@ -478,6 +488,18 @@ function App() {
|
||||
requestEntrySave(draft, editingEntry?.id);
|
||||
}
|
||||
|
||||
function addBarcodeDraft(draft: EntryDraft) {
|
||||
setIsBarcodeScannerOpen(false);
|
||||
requestEntrySave(draft);
|
||||
}
|
||||
|
||||
function editBarcodeDraft(draft: EntryDraft) {
|
||||
setIsBarcodeScannerOpen(false);
|
||||
setEditingEntry(null);
|
||||
setEntryInitialDraft(draft);
|
||||
setIsEntryModalOpen(true);
|
||||
}
|
||||
|
||||
async function quickAdd(item: (typeof QUICK_ADDS)[number]) {
|
||||
if (!user) return;
|
||||
const meta = flavourMeta(item.flavour);
|
||||
@@ -682,6 +704,7 @@ function App() {
|
||||
setupStatus={setupStatus}
|
||||
user={user}
|
||||
onAdd={openNewEntry}
|
||||
onScan={openBarcodeScanner}
|
||||
onChange={setActiveView}
|
||||
onOpenSettings={() => setActiveView("settings")}
|
||||
/>
|
||||
@@ -693,6 +716,7 @@ function App() {
|
||||
activeView={activeView}
|
||||
actionLoading={actionLoading}
|
||||
onAdd={openNewEntry}
|
||||
onScan={openBarcodeScanner}
|
||||
/>
|
||||
|
||||
<StatusRail actionLoading={actionLoading} dataError={dataError} setupStatus={setupStatus} />
|
||||
@@ -721,6 +745,7 @@ function App() {
|
||||
coachSession={coachSession}
|
||||
onQuickAdd={(item) => void quickAdd(item)}
|
||||
onAdd={openNewEntry}
|
||||
onScan={openBarcodeScanner}
|
||||
onOpenCoach={(prompt) => {
|
||||
if (prompt) coachSession.queuePrompt(prompt);
|
||||
setActiveView("coach");
|
||||
@@ -801,6 +826,7 @@ function App() {
|
||||
|
||||
<EntryModal
|
||||
entry={editingEntry}
|
||||
initialDraft={entryInitialDraft}
|
||||
flavours={allFlavours}
|
||||
open={isEntryModalOpen}
|
||||
saving={actionLoading === "save-entry"}
|
||||
@@ -809,10 +835,21 @@ function App() {
|
||||
onClose={() => {
|
||||
setIsEntryModalOpen(false);
|
||||
setEditingEntry(null);
|
||||
setEntryInitialDraft(null);
|
||||
}}
|
||||
onSave={(draft) => void saveEntry(draft)}
|
||||
/>
|
||||
|
||||
<BarcodeScannerModal
|
||||
busy={actionLoading === "save-entry"}
|
||||
flavours={allFlavours}
|
||||
open={isBarcodeScannerOpen}
|
||||
userId={user.$id}
|
||||
onAddNow={addBarcodeDraft}
|
||||
onClose={() => setIsBarcodeScannerOpen(false)}
|
||||
onEditBeforeAdding={editBarcodeDraft}
|
||||
/>
|
||||
|
||||
<ImportPreviewModal
|
||||
busy={actionLoading === "confirm-excel-import"}
|
||||
preview={importPreview}
|
||||
@@ -1000,30 +1037,6 @@ function AuthView({
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSignal({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.06] p-3">
|
||||
<Icon className="mb-3 text-cyan-200" size={18} aria-hidden="true" />
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-slate-400">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentThemeIndicator({
|
||||
theme,
|
||||
onClick,
|
||||
}: {
|
||||
theme: AppTheme;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="theme-indicator" type="button" onClick={onClick} aria-label={`Theme: ${theme.label}. Open settings.`}>
|
||||
<span className="theme-indicator-swatch" style={{ background: theme.swatch }} aria-hidden="true" />
|
||||
<span className="theme-indicator-label">{theme.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePicker({
|
||||
themeId,
|
||||
@@ -1091,6 +1104,7 @@ function Sidebar({
|
||||
setupStatus,
|
||||
user,
|
||||
onAdd,
|
||||
onScan,
|
||||
onChange,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
@@ -1100,6 +1114,7 @@ function Sidebar({
|
||||
setupStatus: SetupStatus;
|
||||
user: AuthUser;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
onChange: (view: AppView) => void;
|
||||
onOpenSettings: () => void;
|
||||
}) {
|
||||
@@ -1120,6 +1135,11 @@ function Sidebar({
|
||||
Add intake
|
||||
</button>
|
||||
|
||||
<button className="secondary-button mb-5 w-full justify-center" type="button" onClick={onScan}>
|
||||
<Camera size={17} aria-hidden="true" />
|
||||
Scan barcode
|
||||
</button>
|
||||
|
||||
<nav className="drawer-nav" aria-label="Main navigation">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
@@ -1175,10 +1195,12 @@ function TopBar({
|
||||
activeView,
|
||||
actionLoading,
|
||||
onAdd,
|
||||
onScan,
|
||||
}: {
|
||||
activeView: AppView;
|
||||
actionLoading: string | null;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
}) {
|
||||
const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0];
|
||||
const title = activeItem.label;
|
||||
@@ -1204,6 +1226,10 @@ function TopBar({
|
||||
</div>
|
||||
|
||||
<div className="top-action-row">
|
||||
<button className="secondary-button justify-center min-h-12 text-sm active:scale-95" type="button" onClick={onScan} disabled={Boolean(actionLoading)}>
|
||||
<Camera size={18} aria-hidden="true" />
|
||||
Scan barcode
|
||||
</button>
|
||||
<button className="primary-button justify-center min-h-12 text-sm active:scale-95" type="button" onClick={onAdd} disabled={Boolean(actionLoading)}>
|
||||
<Plus size={18} aria-hidden="true" />
|
||||
Add Intake
|
||||
@@ -1261,6 +1287,7 @@ function OverviewView({
|
||||
limitCheck,
|
||||
onQuickAdd,
|
||||
onAdd,
|
||||
onScan,
|
||||
onOpenCoach,
|
||||
onOpenLogbook,
|
||||
onOpenSettings,
|
||||
@@ -1278,6 +1305,7 @@ function OverviewView({
|
||||
coachSession: CoachSession;
|
||||
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
onOpenCoach: (prompt?: string) => void;
|
||||
onOpenLogbook: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -1305,7 +1333,7 @@ function OverviewView({
|
||||
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
||||
</section>
|
||||
|
||||
<TodayPanel dashboard={dashboard} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} />
|
||||
<TodayPanel dashboard={dashboard} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
|
||||
|
||||
{limitCheck.violations.length ? (
|
||||
<section className="glass-panel border border-amber-200/20 bg-amber-200/10 p-4 sm:p-5">
|
||||
@@ -1510,12 +1538,14 @@ function TodayPanel({
|
||||
userLimits,
|
||||
limitCheck,
|
||||
onAdd,
|
||||
onScan,
|
||||
}: {
|
||||
dashboard: Dashboard;
|
||||
entries: RedBullEntry[];
|
||||
userLimits: UserLimits;
|
||||
limitCheck: LimitCheckResult;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
}) {
|
||||
const limitSummary = [
|
||||
userLimits.dailyCanLimit != null ? `${limitCheck.todayCans.toFixed(1)}/${userLimits.dailyCanLimit} cans` : null,
|
||||
@@ -1542,6 +1572,10 @@ function TodayPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div className="today-action-row mt-6 hidden flex-wrap items-center gap-2 lg:flex">
|
||||
<button className="secondary-button" type="button" onClick={onScan}>
|
||||
<Camera size={18} aria-hidden="true" />
|
||||
Scan barcode
|
||||
</button>
|
||||
<button className="primary-button" type="button" onClick={onAdd}>
|
||||
<Plus size={18} aria-hidden="true" />
|
||||
Add intake
|
||||
@@ -1761,11 +1795,13 @@ function SpendingPredictionsCard({
|
||||
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||
)[0].dateTime
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entries]);
|
||||
|
||||
const trackingDays = useMemo(() => {
|
||||
const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime());
|
||||
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [firstEntryDate]);
|
||||
|
||||
const activePeriodDays = Math.min(30, trackingDays);
|
||||
@@ -1781,12 +1817,13 @@ function SpendingPredictionsCard({
|
||||
avgDailyCans: totalCans / activePeriodDays,
|
||||
hasData: entries.length > 0,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entries, activePeriodDays]);
|
||||
|
||||
const projectionData = useMemo(() => {
|
||||
return Array.from({ length: projectionDays }).map((_, index) => {
|
||||
const day = index + 1;
|
||||
const dataPoint: any = {
|
||||
const dataPoint: Record<string, string | number> = {
|
||||
label: `Day ${day}`,
|
||||
"Current Path": Number((day * stats.avgDailySpend).toFixed(2)),
|
||||
"Optimal Path (-20%)": Number((day * stats.avgDailySpend * 0.8).toFixed(2)),
|
||||
@@ -2032,7 +2069,7 @@ function SettingsView({
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<button className="secondary-button justify-center" type="button" onClick={() => { typeof window !== 'undefined' && window.location.reload(); }} disabled={dataLoading}>
|
||||
<button className="secondary-button justify-center" type="button" onClick={() => { if (typeof window !== 'undefined') window.location.reload(); }} disabled={dataLoading}>
|
||||
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
|
||||
Sync now
|
||||
</button>
|
||||
@@ -2415,6 +2452,7 @@ function DisclaimerCard() {
|
||||
function EntryModal({
|
||||
open,
|
||||
entry,
|
||||
initialDraft,
|
||||
flavours,
|
||||
saving,
|
||||
userLimits,
|
||||
@@ -2424,6 +2462,7 @@ function EntryModal({
|
||||
}: {
|
||||
open: boolean;
|
||||
entry: RedBullEntry | null;
|
||||
initialDraft: EntryDraft | null;
|
||||
flavours: Flavour[];
|
||||
saving: boolean;
|
||||
userLimits: UserLimits;
|
||||
@@ -2432,37 +2471,39 @@ function EntryModal({
|
||||
onSave: (draft: EntryDraft) => void;
|
||||
}) {
|
||||
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||||
const initialFlavour = entry?.flavour ?? DEFAULT_FLAVOUR.name;
|
||||
const activeDraft = entry ?? initialDraft;
|
||||
const initialFlavour = activeDraft?.flavour ?? DEFAULT_FLAVOUR.name;
|
||||
const [selectedFlavour, setSelectedFlavour] = useState(initialFlavour);
|
||||
const [customFlavour, setCustomFlavour] = useState("");
|
||||
const [customAccent, setCustomAccent] = useState(MATERIAL_ACCENTS.custom);
|
||||
const [cans, setCans] = useState(entry?.cans.toString() ?? "1");
|
||||
const [sizePreset, setSizePreset] = useState(sizeToPreset(entry?.sizeMl ?? 250));
|
||||
const [customSize, setCustomSize] = useState(entry?.sizeMl.toString() ?? "250");
|
||||
const [pricePerCan, setPricePerCan] = useState(entry?.pricePerCan.toString() ?? "1.75");
|
||||
const [dateTime, setDateTime] = useState(formatLocalInput(entry ? new Date(entry.dateTime) : new Date()));
|
||||
const [store, setStore] = useState(entry?.store ?? "");
|
||||
const [notes, setNotes] = useState(entry?.notes ?? "");
|
||||
const [sugarFree, setSugarFree] = useState(entry?.sugarFree ?? false);
|
||||
const [caffeineOverride, setCaffeineOverride] = useState(entry?.caffeineMgPerCan?.toString() ?? "");
|
||||
const [cans, setCans] = useState(activeDraft?.cans.toString() ?? "1");
|
||||
const [sizePreset, setSizePreset] = useState(sizeToPreset(activeDraft?.sizeMl ?? 250));
|
||||
const [customSize, setCustomSize] = useState(activeDraft?.sizeMl.toString() ?? "250");
|
||||
const [pricePerCan, setPricePerCan] = useState(activeDraft?.pricePerCan.toString() ?? "1.75");
|
||||
const [dateTime, setDateTime] = useState(formatLocalInput(activeDraft ? new Date(activeDraft.dateTime) : new Date()));
|
||||
const [store, setStore] = useState(activeDraft?.store ?? "");
|
||||
const [notes, setNotes] = useState(activeDraft?.notes ?? "");
|
||||
const [sugarFree, setSugarFree] = useState(activeDraft?.sugarFree ?? false);
|
||||
const [caffeineOverride, setCaffeineOverride] = useState(activeDraft?.caffeineMgPerCan?.toString() ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const editingCustom = entry && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === entry.flavour);
|
||||
setSelectedFlavour(editingCustom ? entry.flavour : entry?.flavour ?? DEFAULT_FLAVOUR.name);
|
||||
setCustomFlavour(editingCustom ? entry.flavour : "");
|
||||
setCustomAccent(entry?.flavourAccent ?? MATERIAL_ACCENTS.custom);
|
||||
setCans(entry?.cans.toString() ?? "1");
|
||||
setSizePreset(sizeToPreset(entry?.sizeMl ?? 250));
|
||||
setCustomSize(entry?.sizeMl.toString() ?? "250");
|
||||
setPricePerCan(entry?.pricePerCan.toString() ?? defaultPriceForSize(250).toString());
|
||||
setDateTime(formatLocalInput(entry ? new Date(entry.dateTime) : new Date()));
|
||||
setStore(entry?.store ?? "");
|
||||
setNotes(entry?.notes ?? "");
|
||||
setSugarFree(entry?.sugarFree ?? false);
|
||||
setCaffeineOverride(entry?.caffeineMgPerCan?.toString() ?? "");
|
||||
const draft = entry ?? initialDraft;
|
||||
const editingCustom = draft && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === draft.flavour);
|
||||
setSelectedFlavour(editingCustom ? draft.flavour : draft?.flavour ?? DEFAULT_FLAVOUR.name);
|
||||
setCustomFlavour(editingCustom ? draft.flavour : "");
|
||||
setCustomAccent(draft?.flavourAccent ?? MATERIAL_ACCENTS.custom);
|
||||
setCans(draft?.cans.toString() ?? "1");
|
||||
setSizePreset(sizeToPreset(draft?.sizeMl ?? 250));
|
||||
setCustomSize(draft?.sizeMl.toString() ?? "250");
|
||||
setPricePerCan(draft?.pricePerCan.toString() ?? defaultPriceForSize(250).toFixed(2));
|
||||
setDateTime(formatLocalInput(draft ? new Date(draft.dateTime) : new Date()));
|
||||
setStore(draft?.store ?? "");
|
||||
setNotes(draft?.notes ?? "");
|
||||
setSugarFree(draft?.sugarFree ?? false);
|
||||
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
|
||||
window.setTimeout(() => firstFieldRef.current?.focus(), 80);
|
||||
}, [entry, open]);
|
||||
}, [entry, initialDraft, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -2503,7 +2544,7 @@ function EntryModal({
|
||||
store: store.trim(),
|
||||
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
||||
caffeineMgPerCan: override,
|
||||
source: entry?.source ?? "manual",
|
||||
source: entry?.source ?? initialDraft?.source ?? "manual",
|
||||
};
|
||||
}, [
|
||||
open,
|
||||
@@ -2521,6 +2562,7 @@ function EntryModal({
|
||||
sizePreset,
|
||||
caffeineOverride,
|
||||
entry?.source,
|
||||
initialDraft?.source,
|
||||
]);
|
||||
|
||||
const draftLimitCheck = useMemo(() => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
||||
{ name: "Original", accent: "#00A7FF" },
|
||||
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#C3093B" },
|
||||
{ name: "Iced Vanilla", accent: "#49adbe" },
|
||||
{ name: "Tropical", accent: "#FFC247" },
|
||||
{ name: "Watermelon", accent: "#FF355E" },
|
||||
{ name: "Original", accent: "#282874" },
|
||||
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
|
||||
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#B50045" },
|
||||
{ name: "Iced Vanilla", accent: "#53B2C2" },
|
||||
{ name: "Tropical", accent: "#FFCB04" },
|
||||
{ name: "Cherry Edition", accent: "#D81B60" },
|
||||
{ name: "Apricot Edition", accent: "#F3911B" },
|
||||
{ name: "Lilac Sugarfree", accent: "#7D62CE", sugarFree: true },
|
||||
{ name: "Pink Sugarfree", accent: "#E77BAB", sugarFree: true },
|
||||
{ name: "Watermelon", accent: "#E6301F" },
|
||||
{ name: "Blueberry", accent: "#496DFF" },
|
||||
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
||||
{ name: "Peach", accent: "#FF9B63" },
|
||||
{ name: "Juneberry", accent: "#9C73FF" },
|
||||
{ name: "Coconut Berry", accent: "#0070B8" },
|
||||
{ name: "Peach", accent: "#E24585" },
|
||||
{ name: "Juneberry", accent: "#0085C8" },
|
||||
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
||||
{ name: "Winter Edition", accent: "#7CE7FF" },
|
||||
{ name: "Summer Edition", accent: "#f0e53b" },
|
||||
{ name: "Curuba Elderflower", accent: "#78B941" },
|
||||
{ name: "Winter Edition", accent: "#BF1431" },
|
||||
{ name: "Summer Edition", accent: "#F2E853" },
|
||||
{ name: "Other", accent: "#AEB9C7" },
|
||||
];
|
||||
|
||||
|
||||
+101
-99
@@ -51,147 +51,149 @@ export const APP_THEMES: AppTheme[] = [
|
||||
tertiary: "#ffd8e7",
|
||||
}),
|
||||
|
||||
theme("original", "Original", "flavour", "#00a7ff", {
|
||||
primary: "#0077c8",
|
||||
secondary: "#00a7ff",
|
||||
tertiary: "#1e3264",
|
||||
theme("original", "Original", "flavour", "#282874", {
|
||||
primary: "#282874",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d4af37",
|
||||
tokens: {
|
||||
chartSecondary: "#e6301f",
|
||||
},
|
||||
}),
|
||||
theme("zero", "Zero", "flavour", "#2a2a2a", {
|
||||
primary: "#2a2a2a",
|
||||
secondary: "#5c5c5c",
|
||||
tertiary: "#8a8a8a",
|
||||
dark: true,
|
||||
theme("zero", "Zero", "flavour", "#b1d0ee", {
|
||||
primary: "#b1d0ee",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
}),
|
||||
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
|
||||
primary: "#d4c400",
|
||||
secondary: "#f0e53b",
|
||||
tertiary: "#ffc247",
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
}),
|
||||
theme("cherry", "Cherry Edition", "flavour", "#e40046", {
|
||||
primary: "#c3093b",
|
||||
secondary: "#e40046",
|
||||
tertiary: "#ff6b8a",
|
||||
theme("cherry", "Cherry Edition", "flavour", "#d81b60", {
|
||||
primary: "#d81b60",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#b50045",
|
||||
}),
|
||||
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
|
||||
primary: "#e85d8a",
|
||||
secondary: "#ffb3c6",
|
||||
tertiary: "#ffd8e7",
|
||||
}),
|
||||
theme("apple", "Apple Edition", "flavour", "#78be20", {
|
||||
primary: "#5a9a12",
|
||||
secondary: "#78be20",
|
||||
tertiary: "#a8d84a",
|
||||
theme("apple", "Apple Edition", "flavour", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#f3911b",
|
||||
}),
|
||||
theme("peach", "Peach Edition", "flavour", "#ff9b63", {
|
||||
primary: "#e87a3a",
|
||||
secondary: "#ff9b63",
|
||||
tertiary: "#ffc9a3",
|
||||
theme("peach", "Peach Edition", "flavour", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
}),
|
||||
theme("ice", "Ice Edition", "flavour", "#49adbe", {
|
||||
primary: "#2d8a9a",
|
||||
secondary: "#49adbe",
|
||||
tertiary: "#7ce7ff",
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#49adbe",
|
||||
}),
|
||||
theme("blue-edition", "Blue Edition", "flavour", "#496dff", {
|
||||
primary: "#3a52cc",
|
||||
secondary: "#496dff",
|
||||
tertiary: "#9c73ff",
|
||||
theme("blue-edition", "Blue Edition", "flavour", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#ff73d1",
|
||||
}),
|
||||
theme("red-edition", "Red Edition", "flavour", "#ff355e", {
|
||||
primary: "#e02045",
|
||||
secondary: "#ff355e",
|
||||
tertiary: "#ff6b8a",
|
||||
theme("red-edition", "Red Edition", "flavour", "#e6301f", {
|
||||
primary: "#e6301f",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#78b941",
|
||||
}),
|
||||
theme("tropical", "Tropical Edition", "flavour", "#ffc247", {
|
||||
primary: "#e0a820",
|
||||
secondary: "#ffc247",
|
||||
tertiary: "#ff9b63",
|
||||
theme("tropical", "Tropical Edition", "flavour", "#ffcb04", {
|
||||
primary: "#ffcb04",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f6c300",
|
||||
}),
|
||||
theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", {
|
||||
primary: "#4ec4e0",
|
||||
secondary: "#7ce7ff",
|
||||
tertiary: "#d8f9ff",
|
||||
theme("coconut", "Coconut Edition", "flavour", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
}),
|
||||
theme("green-edition", "Green Edition", "flavour", "#b7ff4a", {
|
||||
primary: "#7acc20",
|
||||
secondary: "#b7ff4a",
|
||||
tertiary: "#d4ff8a",
|
||||
theme("green-edition", "Green Edition", "flavour", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f3911b",
|
||||
}),
|
||||
theme("apricot", "Apricot Edition", "flavour", "#ff8c42", {
|
||||
primary: "#e06a20",
|
||||
secondary: "#ff8c42",
|
||||
tertiary: "#ffb87a",
|
||||
theme("apricot", "Apricot Edition", "flavour", "#f3911b", {
|
||||
primary: "#f3911b",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
}),
|
||||
theme("ruby", "Ruby Edition", "flavour", "#c3093b", {
|
||||
primary: "#a00730",
|
||||
secondary: "#c3093b",
|
||||
tertiary: "#e04060",
|
||||
theme("ruby", "Ruby Edition", "flavour", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#a3e635",
|
||||
}),
|
||||
|
||||
theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", {
|
||||
primary: "#8a9bb0",
|
||||
secondary: "#c8d4e0",
|
||||
tertiary: "#e7eef8",
|
||||
theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", {
|
||||
primary: "#009edf",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", {
|
||||
primary: "#c4c020",
|
||||
secondary: "#e8e4a0",
|
||||
tertiary: "#f0e53b",
|
||||
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", {
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", {
|
||||
primary: "#6a9a30",
|
||||
secondary: "#b8d4a0",
|
||||
tertiary: "#78be20",
|
||||
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", {
|
||||
primary: "#d08050",
|
||||
secondary: "#f0d0b8",
|
||||
tertiary: "#ff9b63",
|
||||
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", {
|
||||
primary: "#4a9aaa",
|
||||
secondary: "#b8e0e8",
|
||||
tertiary: "#49adbe",
|
||||
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", {
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", {
|
||||
primary: "#9070c0",
|
||||
secondary: "#d8c8f0",
|
||||
tertiary: "#b898e0",
|
||||
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", {
|
||||
primary: "#7d62ce",
|
||||
secondary: "#44c7b7",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", {
|
||||
primary: "#d06090",
|
||||
secondary: "#f0c8d8",
|
||||
tertiary: "#ffb7d9",
|
||||
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", {
|
||||
primary: "#e77bab",
|
||||
secondary: "#8a1f3d",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", {
|
||||
primary: "#5060c0",
|
||||
secondary: "#c8d0f8",
|
||||
tertiary: "#496dff",
|
||||
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", {
|
||||
primary: "#60b8d0",
|
||||
secondary: "#d0f0f8",
|
||||
tertiary: "#7ce7ff",
|
||||
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", {
|
||||
primary: "#70a830",
|
||||
secondary: "#d8f0b8",
|
||||
tertiary: "#b7ff4a",
|
||||
theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", {
|
||||
primary: "#a03050",
|
||||
secondary: "#f0c0c8",
|
||||
tertiary: "#c3093b",
|
||||
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
|
||||
|
||||
@@ -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",
|
||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
||||
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
||||
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user.$id]);
|
||||
|
||||
const upsertChatState = useCallback((chat: CoachChat) => {
|
||||
@@ -240,7 +241,7 @@ export function useCoachSession(
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[activeChat, busy, dashboard, entries, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, withAssistantMessage],
|
||||
[activeChat, busy, dashboard, entries, limitCheck, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, userLimits, withAssistantMessage],
|
||||
);
|
||||
|
||||
const queuePrompt = useCallback((prompt: string) => {
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
|
||||
export type BarcodeFormatName = "ean-13" | "ean-8" | "upc-a" | "upc-e" | "unknown";
|
||||
|
||||
export type BarcodeProductDraft = {
|
||||
flavourName: string;
|
||||
sizeMl: number;
|
||||
pricePerCan: number;
|
||||
sugarFree?: boolean;
|
||||
caffeineMgPerCan?: number;
|
||||
};
|
||||
|
||||
export type ResolvedBarcodeProduct = BarcodeProductDraft & {
|
||||
flavourAccent: string;
|
||||
source: "built-in" | "user";
|
||||
};
|
||||
|
||||
export type BarcodeSeedProduct = BarcodeProductDraft & {
|
||||
verifiedBy: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
notes?: string;
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
export type UserBarcodeMapping = BarcodeProductDraft & {
|
||||
barcode: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type BarcodeLookupCatalog = {
|
||||
verifiedProducts?: Record<string, BarcodeSeedProduct>;
|
||||
userMappings?: UserBarcodeMapping[];
|
||||
};
|
||||
|
||||
export type BarcodeLookupResult =
|
||||
| {
|
||||
status: "known" | "user";
|
||||
barcode: string;
|
||||
product: ResolvedBarcodeProduct;
|
||||
}
|
||||
| {
|
||||
status: "partial";
|
||||
barcode: string;
|
||||
product: BarcodeProductDraft;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
status: "unknown";
|
||||
barcode: string;
|
||||
};
|
||||
|
||||
export type Filters = {
|
||||
flavour: string;
|
||||
dateRange: DateFilter;
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||
readonly VITE_OLLAMA_PROXY_URL?: string;
|
||||
|
||||
Reference in New Issue
Block a user