commit edd0af342e4c62963ea3e1ac519d986f636c1c47 Author: Isaac Aracena Date: Thu Aug 6 08:03:32 2026 -0400 Initial production-ready Lucozade audit dashboard diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..373ca15 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_SUPABASE_URL="https://YOUR-SUPABASE-DOMAIN" +VITE_SUPABASE_ANON_KEY="YOUR_ANON_KEY" +VITE_N8N_AUTH_WEBHOOK_URL="https://YOUR-N8N-DOMAIN/webhook/lucozade-auth-v6" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14bdfa6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +node_modules/ +build/ +dist/ +coverage/ +.DS_Store +*.log +.env* +!.env.example + +# Dependencias y compilaci髇 +node_modules/ +dist/ + +# Variables privadas +.env +.env.local +.env.production + +# Archivos temporales +*.log +.DS_Store + +# Workflow n8n con credenciales privadas +automation/ +Lucozade_Auth_Direct_n8n_*.json diff --git a/Lucozade_Auth_V6_Supabase.sql b/Lucozade_Auth_V6_Supabase.sql new file mode 100644 index 0000000..61bf83a --- /dev/null +++ b/Lucozade_Auth_V6_Supabase.sql @@ -0,0 +1,146 @@ +-- Lucozade Store Audit 路 Auth V6 +-- Ejecutar una vez en Supabase SQL Editor. +-- Es idempotente y NO elimina usuarios de auth.users. +-- V6 usa los enlaces de recuperaci贸n nativos de Supabase Auth; +-- por eso elimina la tabla y los RPC personalizados de recuperaci贸n de V5. + +begin; + +-- Objetos obsoletos de V5 (ya no son necesarios). +drop function if exists public.lucozade_consume_password_reset(text, text); +drop function if exists public.lucozade_issue_password_reset(uuid, text); +drop function if exists public.lucozade_find_recoverable_user_by_email(text); +drop table if exists public.lucozade_password_reset_tokens; + +-- Acceso espec铆fico a la aplicaci贸n. +create table if not exists public.lucozade_access ( + user_id uuid primary key references auth.users(id) on delete cascade, + email text not null, + full_name text, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Compatibilidad si la tabla ven铆a de una versi贸n anterior. +alter table public.lucozade_access + add column if not exists email text, + add column if not exists full_name text, + add column if not exists is_active boolean not null default true, + add column if not exists created_at timestamptz not null default now(), + add column if not exists updated_at timestamptz not null default now(); + +update public.lucozade_access +set email = lower(coalesce(email, '')) +where email is distinct from lower(coalesce(email, '')); + +alter table public.lucozade_access + alter column email set not null; + +create index if not exists lucozade_access_email_idx + on public.lucozade_access (lower(email)); + +create or replace function public.lucozade_set_updated_at() +returns trigger +language plpgsql +security invoker +set search_path = public +as $$ +begin + new.email = lower(trim(new.email)); + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists lucozade_access_set_updated_at on public.lucozade_access; +create trigger lucozade_access_set_updated_at +before update on public.lucozade_access +for each row execute function public.lucozade_set_updated_at(); + +-- Los usuarios creados por el workflow V6 llevan app_id=lucozade-audit. +-- Este trigger crea el acceso autom谩ticamente; n8n tambi茅n hace un upsert +-- como segunda garant铆a antes de responder que el registro termin贸. +create or replace function public.handle_new_lucozade_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if coalesce(new.raw_user_meta_data ->> 'app_id', '') = 'lucozade-audit' then + insert into public.lucozade_access ( + user_id, + email, + full_name, + is_active + ) + values ( + new.id, + lower(coalesce(new.email, '')), + nullif(trim(coalesce(new.raw_user_meta_data ->> 'full_name', '')), ''), + true + ) + on conflict (user_id) do update + set email = excluded.email, + full_name = excluded.full_name, + is_active = true, + updated_at = now(); + end if; + + return new; +end; +$$; + +drop trigger if exists on_lucozade_auth_user_created on auth.users; +create trigger on_lucozade_auth_user_created +after insert on auth.users +for each row execute function public.handle_new_lucozade_user(); + +-- Recupera accesos de usuarios Lucozade existentes que pudieran haber sido +-- creados antes de instalar el trigger. +insert into public.lucozade_access ( + user_id, + email, + full_name, + is_active +) +select + u.id, + lower(coalesce(u.email, '')), + nullif(trim(coalesce(u.raw_user_meta_data ->> 'full_name', '')), ''), + true +from auth.users as u +where coalesce(u.raw_user_meta_data ->> 'app_id', '') = 'lucozade-audit' + and coalesce(u.email, '') <> '' +on conflict (user_id) do update + set email = excluded.email, + full_name = coalesce(excluded.full_name, public.lucozade_access.full_name), + updated_at = now(); + +alter table public.lucozade_access enable row level security; + +drop policy if exists "Lucozade users can read their active access" + on public.lucozade_access; + +create policy "Lucozade users can read their active access" +on public.lucozade_access +for select +to authenticated +using (auth.uid() = user_id and is_active = true); + +revoke all on public.lucozade_access from anon; +grant usage on schema public to authenticated, service_role; +grant select on public.lucozade_access to authenticated; +grant select, insert, update, delete on public.lucozade_access to service_role; + +notify pgrst, 'reload schema'; + +commit; + +-- IMPORTANTE: +-- Borrar una fila de public.lucozade_access revoca el acceso a Lucozade, +-- pero NO elimina la cuenta real de Supabase Auth. +-- Para eliminar totalmente una cuenta, use Authentication > Users +-- o, con extremo cuidado: +-- delete from auth.users where lower(email)=lower('correo@ejemplo.com'); diff --git a/README.md b/README.md new file mode 100644 index 0000000..a288b24 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Lucozade Store Audit 路 Login V6 + +Versi贸n preparada para funcionar en: + +- Local: `http://localhost:3000/` +- Producci贸n: `https://digitalcompass.agency/lucozade/` + +## Incluye + +- Inicio de sesi贸n con correo y contrase帽a. +- Registro seguro mediante n8n y la API administrativa de Supabase Auth. +- **Remember me**: recuerda el correo y conserva la sesi贸n en el navegador. +- Sesi贸n temporal por pesta帽a cuando Remember me est谩 desmarcado. +- Recuperaci贸n mediante enlace nativo de Supabase Auth enviado por Gmail desde n8n. +- Pantalla real para crear una contrase帽a nueva al abrir el enlace. +- Bot贸n **Sign out** dentro de la aplicaci贸n. +- Vite con `/` durante desarrollo y `/lucozade/` en el build de producci贸n. +- Rollback autom谩tico: si Supabase crea el usuario pero falla el acceso de la aplicaci贸n, n8n elimina el usuario parcial para que el correo no quede bloqueado. + +## Archivos importantes + +- `Lucozade_Auth_Direct_n8n_V6_READY.json`: workflow completo de n8n. +- `Lucozade_Auth_V6_Supabase.sql`: script idempotente de Supabase. +- `.env`: variables de la aplicaci贸n. +- `vite.config.ts`: base local/producci贸n. + +## Instalaci贸n + +1. Ejecutar `Lucozade_Auth_V6_Supabase.sql` en Supabase SQL Editor. +2. Importar `Lucozade_Auth_Direct_n8n_V6_READY.json` en n8n. +3. Confirmar la credencial Gmail en: + - `Send Welcome with Gmail` + - `Send Reset with Gmail` +4. Guardar y publicar/activar el workflow. +5. Ejecutar en la aplicaci贸n: + +```bash +npm install +npm run build +``` + +6. Publicar el contenido interno de `dist` en `/lucozade/`. + +## ADDITIONAL_REDIRECT_URLS + +Agregar al final, sin borrar los valores actuales: + +```text +https://digitalcompass.agency/lucozade/,https://digitalcompass.agency/lucozade,http://localhost:3000/,http://127.0.0.1:3000/,http://localhost:5173/,http://127.0.0.1:5173/,http://localhost:4173/lucozade/,http://127.0.0.1:4173/lucozade/ +``` + +No requiere SMTP, Send Email Hook ni cambios en Auth/GoTrue. + +## Comportamiento correcto de las cuentas + +- La cuenta real vive en `auth.users`. +- `public.lucozade_access` solo determina si esa cuenta puede entrar a Lucozade. +- Borrar `lucozade_access` revoca el acceso, pero no elimina la cuenta ni su contrase帽a. +- Para eliminar totalmente una cuenta, usar **Authentication 鈫 Users** en Supabase. +- Intentar registrar un correo que ya existe no cambia su contrase帽a; la aplicaci贸n indica usar **Sign in** o **Forgot password**. diff --git a/SETUP_RAPIDO.txt b/SETUP_RAPIDO.txt new file mode 100644 index 0000000..1368da8 --- /dev/null +++ b/SETUP_RAPIDO.txt @@ -0,0 +1,37 @@ +LUCOZADE STORE AUDIT V6 - CONFIGURACI脫N R脕PIDA + +1) SUPABASE +Ejecuta: +Lucozade_Auth_V6_Supabase.sql + +2) N8N +Importa: +Lucozade_Auth_Direct_n8n_V6_READY.json + +Confirma la credencial en: +- Send Welcome with Gmail +- Send Reset with Gmail + +Guarda y publica/activa el workflow. +Webhook: +https://agenteit.digitalcompass.agency/webhook/lucozade-auth-v6 + +3) EASYPANEL / SUPABASE +A帽ade al final de ADDITIONAL_REDIRECT_URLS: +https://digitalcompass.agency/lucozade/,https://digitalcompass.agency/lucozade,http://localhost:3000/,http://127.0.0.1:3000/,http://localhost:5173/,http://127.0.0.1:5173/,http://localhost:4173/lucozade/,http://127.0.0.1:4173/lucozade/ + +4) LOCAL +npm install +npm run dev + +Abrir: +http://localhost:3000/ + +5) PRODUCCI脫N +npm run build + +Subir el contenido interno de dist a: +/lucozade/ + +URL final: +https://digitalcompass.agency/lucozade/ diff --git a/VALIDACION_V6.txt b/VALIDACION_V6.txt new file mode 100644 index 0000000..77dbbe8 --- /dev/null +++ b/VALIDACION_V6.txt @@ -0,0 +1,107 @@ +LUCOZADE STORE AUDIT V6 - VALIDACI脫N EST脕TICA + +Resultado: 100 comprobaciones superadas. + +OK - Workflow V6 identificado +OK - Webhook V6 correcto +OK - CORS incluye https://digitalcompass.agency +OK - CORS incluye http://localhost:3000 +OK - CORS incluye http://127.0.0.1:3000 +OK - CORS incluye http://localhost:5173 +OK - CORS incluye http://127.0.0.1:5173 +OK - CORS incluye http://localhost:4173 +OK - CORS incluye http://127.0.0.1:4173 +OK - URL de producci贸n correcta +OK - Sender Name fuera de CONFIG +OK - Sender Name correcto en Send Welcome with Gmail +OK - Sender Name correcto en Send Reset with Gmail +OK - Registro usa API Admin de Supabase +OK - Registro transmite contrase帽a al API Admin +OK - Registro confirma el correo para acceso inmediato +OK - Recuperaci贸n usa generate_link nativo +OK - Enlace generado como recovery +OK - Redirect din谩mico local/producci贸n +OK - Workflow no depende de RPC de token personalizado +OK - Code nodes no dependen del constructor URL de n8n +OK - Sintaxis JS v谩lida: Validate Request +OK - Sintaxis JS v谩lida: Prepare Registration +OK - Sintaxis JS v谩lida: Prepare Access Grant +OK - Sintaxis JS v谩lida: Build Welcome Email +OK - Sintaxis JS v谩lida: Finalize Registration +OK - Sintaxis JS v谩lida: Finalize Registration Failure +OK - Sintaxis JS v谩lida: Prepare Recovery Access +OK - Sintaxis JS v谩lida: Build Password Reset Email +OK - Sintaxis JS v谩lida: Finalize Recovery Email +OK - .env apunta al webhook V6 +OK - Base Vite local/producci贸n correcta +OK - Redirect se calcula con BASE_URL +OK - Solicitud al webhook evita preflight CORS +OK - App detecta sesi贸n recovery en URL +OK - App actualiza contrase帽a con sesi贸n recovery +OK - Interfaz incluye Remember me +OK - Interfaz incluye pantalla de contrase帽a nueva +OK - App conecta bot贸n Sign out +OK - SQL elimina mecanismo V5 obsoleto +OK - Acceso se elimina al borrar usuario Auth +OK - Service role no est谩 expuesta en el frontend +OK - C贸digo de la app sin referencias GomezLee +OK - Nodo fuente existe: Lucozade Auth Webhook +OK - Conexi贸n v谩lida: Lucozade Auth Webhook -> CONFIG 路 Supabase +OK - Nodo fuente existe: CONFIG 路 Supabase +OK - Conexi贸n v谩lida: CONFIG 路 Supabase -> Validate Request +OK - Nodo fuente existe: Validate Request +OK - Conexi贸n v谩lida: Validate Request -> Request Valid? +OK - Nodo fuente existe: Request Valid? +OK - Conexi贸n v谩lida: Request Valid? -> Register Request? +OK - Conexi贸n v谩lida: Request Valid? -> Respond to Application +OK - Nodo fuente existe: Register Request? +OK - Conexi贸n v谩lida: Register Request? -> Create Supabase User +OK - Conexi贸n v谩lida: Register Request? -> Find Active Lucozade Access +OK - Nodo fuente existe: Create Supabase User +OK - Conexi贸n v谩lida: Create Supabase User -> Prepare Registration +OK - Nodo fuente existe: Prepare Registration +OK - Conexi贸n v谩lida: Prepare Registration -> Registration Created? +OK - Nodo fuente existe: Registration Created? +OK - Conexi贸n v谩lida: Registration Created? -> Grant Lucozade Access +OK - Conexi贸n v谩lida: Registration Created? -> Respond to Application +OK - Nodo fuente existe: Grant Lucozade Access +OK - Conexi贸n v谩lida: Grant Lucozade Access -> Prepare Access Grant +OK - Nodo fuente existe: Prepare Access Grant +OK - Conexi贸n v谩lida: Prepare Access Grant -> Access Granted? +OK - Nodo fuente existe: Access Granted? +OK - Conexi贸n v谩lida: Access Granted? -> Build Welcome Email +OK - Conexi贸n v谩lida: Access Granted? -> Roll Back Partial User +OK - Nodo fuente existe: Build Welcome Email +OK - Conexi贸n v谩lida: Build Welcome Email -> Welcome Email Ready? +OK - Nodo fuente existe: Welcome Email Ready? +OK - Conexi贸n v谩lida: Welcome Email Ready? -> Send Welcome with Gmail +OK - Conexi贸n v谩lida: Welcome Email Ready? -> Respond to Application +OK - Nodo fuente existe: Send Welcome with Gmail +OK - Conexi贸n v谩lida: Send Welcome with Gmail -> Finalize Registration +OK - Nodo fuente existe: Finalize Registration +OK - Conexi贸n v谩lida: Finalize Registration -> Respond to Application +OK - Nodo fuente existe: Roll Back Partial User +OK - Conexi贸n v谩lida: Roll Back Partial User -> Finalize Registration Failure +OK - Nodo fuente existe: Finalize Registration Failure +OK - Conexi贸n v谩lida: Finalize Registration Failure -> Respond to Application +OK - Nodo fuente existe: Find Active Lucozade Access +OK - Conexi贸n v谩lida: Find Active Lucozade Access -> Prepare Recovery Access +OK - Nodo fuente existe: Prepare Recovery Access +OK - Conexi贸n v谩lida: Prepare Recovery Access -> Recovery Authorized? +OK - Nodo fuente existe: Recovery Authorized? +OK - Conexi贸n v谩lida: Recovery Authorized? -> Generate Supabase Recovery Link +OK - Conexi贸n v谩lida: Recovery Authorized? -> Respond to Application +OK - Nodo fuente existe: Generate Supabase Recovery Link +OK - Conexi贸n v谩lida: Generate Supabase Recovery Link -> Build Password Reset Email +OK - Nodo fuente existe: Build Password Reset Email +OK - Conexi贸n v谩lida: Build Password Reset Email -> Reset Email Ready? +OK - Nodo fuente existe: Reset Email Ready? +OK - Conexi贸n v谩lida: Reset Email Ready? -> Send Reset with Gmail +OK - Conexi贸n v谩lida: Reset Email Ready? -> Respond to Application +OK - Nodo fuente existe: Send Reset with Gmail +OK - Conexi贸n v谩lida: Send Reset with Gmail -> Finalize Recovery Email +OK - Nodo fuente existe: Finalize Recovery Email +OK - Conexi贸n v谩lida: Finalize Recovery Email -> Respond to Application + +Adem谩s se ejecut贸 el harness de escenarios del workflow y la transpilaci贸n sint谩ctica TS/TSX. +La prueba en vivo depende del Supabase y n8n del servidor del usuario. diff --git a/index.html b/index.html new file mode 100644 index 0000000..c91a1a1 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + Lucozade Store Audit + + +
+ + + diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..f7d46e8 --- /dev/null +++ b/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "Lucozade Audit Dashboard", + "description": "Dashboard de auditor铆a e inventarios para Lucozade Sport Ice Kick con integraci贸n Google Sheets", + "requestFramePermissions": [], + "majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..37dbdd0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2531 @@ +{ + "name": "lucozade-audit-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lucozade-audit-dashboard", + "version": "1.0.0", + "dependencies": { + "lucide-react": "^0.546.0", + "papaparse": "^5.5.4", + "react": "^19.0.1", + "react-dom": "^19.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.14", + "@types/node": "^22.14.0", + "@types/papaparse": "^5.5.2", + "@vitejs/plugin-react": "^5.0.4", + "tailwindcss": "^4.1.14", + "typescript": "~5.8.2", + "vite": "^6.2.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.546.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz", + "integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f21f4ec --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "lucozade-audit-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --port=3000 --host=0.0.0.0", + "build": "vite build", + "preview": "vite preview --host=0.0.0.0", + "clean": "rm -rf dist", + "lint": "tsc --noEmit" + }, + "dependencies": { + "lucide-react": "^0.546.0", + "papaparse": "^5.5.4", + "react": "^19.0.1", + "react-dom": "^19.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.14", + "@types/node": "^22.14.0", + "@types/papaparse": "^5.5.2", + "@vitejs/plugin-react": "^5.0.4", + "tailwindcss": "^4.1.14", + "typescript": "~5.8.2", + "vite": "^6.2.3" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..a6a7c74 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,608 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { AuthSession, RecoverySession, clearAuthActionFromUrl, getRecoverySessionFromUrl, getStoredSession, refreshSession, signOut } from './services/supabaseAuth'; +import { fetchGoogleSheetsData, DEFAULT_SPREADSHEET_ID, FetchResult, mergeAuditData } from './services/googleSheets'; +import { FilterState, MergedStoreAudit, DashboardKPIs, Store } from './types'; +import { extractWeekOptions, isDateInWeek, isItemInWeek } from './utils/dateUtils'; + +import { Header } from './components/Header'; +import { FilterBar } from './components/FilterBar'; +import { KPICards } from './components/KPICards'; +import { AuditStep1Presencia } from './components/AuditStep1Presencia'; +import { AuditStep2Exhibicion } from './components/AuditStep2Exhibicion'; +import { AuditStep3Estante } from './components/AuditStep3Estante'; +import { AuditStep4Inventario } from './components/AuditStep4Inventario'; +import { StoreTable } from './components/StoreTable'; +import { StoreAuditModal } from './components/StoreAuditModal'; +import { SyncSheetModal } from './components/SyncSheetModal'; +import { ClientTutorialModal } from './components/ClientTutorialModal'; +import { AuthScreen } from './components/AuthScreen'; + +import { LayoutDashboard, Eye, MapPin, BarChart3, PackageCheck, Table, AlertCircle, LoaderCircle, Zap } from 'lucide-react'; + +// Helper to deduplicate stores strictly by Customer Name (Store Name) +function getUniqueStores(stores: Store[]): Store[] { + const map = new Map(); + stores.forEach(s => { + if (!s.customer || !s.customer.trim()) return; + const key = s.customer.trim().toLowerCase(); + if (!map.has(key)) { + map.set(key, s); + } else { + const existing = map.get(key)!; + if (!existing.assignedDate && s.assignedDate) { + map.set(key, s); + } + } + }); + return Array.from(map.values()); +} + +export default function App() { + const [authSession, setAuthSession] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const [recoverySession, setRecoverySession] = useState(null); + const [recoveryError, setRecoveryError] = useState(''); + const [spreadsheetId, setSpreadsheetId] = useState(DEFAULT_SPREADSHEET_ID); + const [isSyncing, setIsSyncing] = useState(false); + + const [dataResult, setDataResult] = useState(null); + const [activeTab, setActiveTab] = useState<'overview' | 'step1' | 'step2' | 'step3' | 'step4' | 'table'>('overview'); + + const [selectedAuditItem, setSelectedAuditItem] = useState(null); + const [isSheetModalOpen, setIsSheetModalOpen] = useState(false); + const [isTutorialOpen, setIsTutorialOpen] = useState(false); + + // Filters State + const [filters, setFilters] = useState({ + zone: 'all', + channel: 'all', + customer: 'all', + status: 'all', + oosStatus: 'all', + selectedWeek: 'all', + search: '' + }); + + // Supabase email/password session initialization + useEffect(() => { + let mounted = true; + + const initializeAuth = async () => { + try { + const recovery = await getRecoverySessionFromUrl(); + if (recovery.session || recovery.error) { + if (recovery.error) clearAuthActionFromUrl(); + if (mounted) { + setRecoverySession(recovery.session); + setRecoveryError(recovery.error); + setAuthSession(null); + setAuthLoading(false); + } + return; + } + + const storedSession = await getStoredSession(); + if (mounted) { + setAuthSession(storedSession); + setAuthLoading(false); + } + } catch (error) { + console.error('Unable to restore the authentication session:', error); + if (mounted) { + setAuthSession(null); + setRecoverySession(null); + setRecoveryError(''); + setAuthLoading(false); + } + } + }; + + initializeAuth(); + return () => { + mounted = false; + }; + }, []); + + const loadData = async (spId: string) => { + setIsSyncing(true); + try { + const res = await fetchGoogleSheetsData(spId, null); + setDataResult(res); + } catch (e) { + console.error('Error loading data:', e); + } finally { + setIsSyncing(false); + } + }; + + useEffect(() => { + if (authSession && !recoverySession && !dataResult) { + loadData(spreadsheetId); + } + }, [authSession, recoverySession]); + + useEffect(() => { + if (!authSession) return; + + const refreshIn = Math.max(30_000, (authSession.expires_at * 1000) - Date.now() - 60_000); + const timer = window.setTimeout(async () => { + const refreshed = await refreshSession(authSession); + if (refreshed) { + setAuthSession(refreshed); + } else { + setAuthSession(null); + setDataResult(null); + } + }, refreshIn); + + return () => window.clearTimeout(timer); + }, [authSession]); + + const handleRefresh = () => { + loadData(spreadsheetId); + }; + + const handleResetFilters = () => { + setFilters({ + zone: 'all', + channel: 'all', + customer: 'all', + status: 'all', + oosStatus: 'all', + selectedWeek: 'all', + search: '' + }); + }; + + // Extract week options for Monday - Sunday selection + const weekOptions = useMemo(() => { + if (!dataResult) return []; + return extractWeekOptions(dataResult.stores, dataResult.responses); + }, [dataResult]); + + // Auto-select initial active week when weekOptions load + useEffect(() => { + if (weekOptions.length > 0 && filters.selectedWeek === 'all') { + const target = weekOptions.find(w => w.key.includes('2026-07-20')) || weekOptions[0]; + setFilters(prev => ({ ...prev, selectedWeek: target.key })); + } + }, [weekOptions]); + + // Compute Base Merged Items according to selectedWeek filter + const baseMergedData = useMemo(() => { + if (!dataResult) return []; + + const { stores, responses } = dataResult; + const selectedWeek = filters.selectedWeek || 'all'; + + if (selectedWeek !== 'all') { + // Filter stores by 'week' column or assignedDate falling in selectedWeek + const weekStores = stores.filter(s => isItemInWeek(s.week, s.assignedDate, selectedWeek)); + const uniqueWeekStores = getUniqueStores(weekStores); + + // Distinct count of stores from 'tiendas' sheet for this week + const targetStores = uniqueWeekStores.length > 0 ? uniqueWeekStores : getUniqueStores(stores); + + // Filter responses by 'week' column or submissionDate falling in selectedWeek + const weekResponses = responses.filter(r => isItemInWeek(r.week, r.submissionDate, selectedWeek)); + + return mergeAuditData(targetStores, weekResponses); + } else { + // All weeks selected: deduplicate stores strictly by Customer name + const uniqueStores = getUniqueStores(stores); + return mergeAuditData(uniqueStores, responses); + } + }, [dataResult, filters.selectedWeek]); + + // Filtered Merged Items + const filteredMergedData = useMemo(() => { + return baseMergedData.filter(item => { + // Zone + if (filters.zone !== 'all' && item.store.zone !== filters.zone) return false; + // Channel + if (filters.channel !== 'all' && item.store.customerChannel !== filters.channel) return false; + // Customer + if (filters.customer !== 'all' && item.store.customer !== filters.customer) return false; + // Audit Status + if (filters.status !== 'all' && item.status !== filters.status) return false; + // OOS Status + if (filters.oosStatus === 'Available' && item.response?.isAvailable !== 'Yes') return false; + if (filters.oosStatus === 'OOS' && item.response?.isAvailable !== 'No') return false; + + // Search Query + if (filters.search.trim()) { + const q = filters.search.toLowerCase().trim(); + const matchesCode = item.store.customerCode.toLowerCase().includes(q); + const matchesName = item.store.customer.toLowerCase().includes(q); + if (!matchesCode && !matchesName) return false; + } + + return true; + }); + }, [baseMergedData, filters]); + + // Dashboard KPIs Calculation + const kpis: DashboardKPIs = useMemo(() => { + const totalStores = filteredMergedData.length; + const visitedItems = filteredMergedData.filter(m => m.status === 'Visited'); + const visitedStores = visitedItems.length; + const pendingStores = totalStores - visitedStores; + + const complianceRate = totalStores > 0 ? (visitedStores / totalStores) * 100 : 0; + + // Total raw response submissions matching the filtered stores in the active week + const activeResponses = (filters.selectedWeek && filters.selectedWeek !== 'all') + ? (dataResult?.responses || []).filter(r => r.submissionDate && isDateInWeek(r.submissionDate, filters.selectedWeek)) + : (dataResult?.responses || []); + + let totalResponses = visitedStores; + if (activeResponses.length > 0) { + const storeNamesSet = new Set(filteredMergedData.map(m => m.store.customer.toLowerCase().trim())); + const matchingResponses = activeResponses.filter(r => r.customer && storeNamesSet.has(r.customer.toLowerCase().trim())); + if (matchingResponses.length > 0) { + totalResponses = matchingResponses.length; + } + } + + // Available and Out of Stock stores in filtered dataset + const availableStores = visitedItems.filter(m => m.response?.isAvailable === 'Yes').length; + const oosStores = visitedItems.filter(m => m.response?.isAvailable === 'No').length; + + // Denominator for rates based on visited stores + const denominator = visitedStores > 0 ? visitedStores : totalStores; + const oosRate = denominator > 0 ? (oosStores / denominator) * 100 : 0; + const numericDistribution = denominator > 0 ? (availableStores / denominator) * 100 : 0; + + let totalPhysicalInventory = 0; + let totalUnitsSoldIn = 0; + let priceSum = 0; + let priceCount = 0; + let facingsSum = 0; + let totalIceKickFacings = 0; + let totalLucozadeFacings = 0; + let totalCategoryFacings = 0; + + const visitedAvailableStores = filteredMergedData.filter(m => m.status === 'Visited' && m.response?.isAvailable === 'Yes'); + + filteredMergedData.forEach(item => { + totalUnitsSoldIn += item.store.unitsSoldIn || 0; + if (item.response) { + totalPhysicalInventory += item.response.totalPhysicalInventory || 0; + } + }); + + visitedAvailableStores.forEach(item => { + if (item.response) { + if (item.response.retailPrice && item.response.retailPrice > 0) { + priceSum += item.response.retailPrice; + priceCount++; + } + facingsSum += item.response.facingsIceKick || 0; + totalIceKickFacings += item.response.facingsIceKick || 0; + totalLucozadeFacings += item.response.facingsLucozadeBrand || 0; + totalCategoryFacings += item.response.facingsCategoryTotal || 0; + } + }); + + const avgRetailPrice = priceCount > 0 ? priceSum / priceCount : 0; + const avgIceKickFacings = visitedStores > 0 ? facingsSum / visitedStores : 0; + const avgBrandShelfShare = totalLucozadeFacings > 0 ? (totalIceKickFacings / totalLucozadeFacings) * 100 : 0; + const avgCategoryShelfShare = totalCategoryFacings > 0 ? (totalLucozadeFacings / totalCategoryFacings) * 100 : 0; + + // Placement & Additional Exhibition Metrics (Secondary Display & Gondola End) + let mainShelfStores = 0; + let secondaryDisplayStores = 0; + let gondolaEndStores = 0; + let additionalExhibitionStores = 0; + + visitedItems.forEach(m => { + const loc = (m.response?.placementLocation || '').toLowerCase(); + const hasMain = loc.includes('main') || loc.includes('shelf') || loc.includes('estante'); + const hasSecondary = loc.includes('secondary') || loc.includes('secundaria') || loc.includes('exhibici') || loc.includes('display'); + const hasGondola = loc.includes('gondola') || loc.includes('g贸ndola') || loc.includes('end') || loc.includes('cabecera') || loc.includes('endcap'); + + if (hasMain) mainShelfStores++; + if (hasSecondary) secondaryDisplayStores++; + if (hasGondola) gondolaEndStores++; + if (hasSecondary || hasGondola) additionalExhibitionStores++; + }); + + const mainShelfRate = denominator > 0 ? (mainShelfStores / denominator) * 100 : 0; + const secondaryDisplayRate = denominator > 0 ? (secondaryDisplayStores / denominator) * 100 : 0; + const gondolaEndRate = denominator > 0 ? (gondolaEndStores / denominator) * 100 : 0; + const additionalExhibitionRate = denominator > 0 ? (additionalExhibitionStores / denominator) * 100 : 0; + + let totalSellOutUnits = 0; + let totalSellOutValue = 0; + + visitedItems.forEach(item => { + const inv = item.response?.totalPhysicalInventory || 0; + const price = item.response?.retailPrice || 0; + const sellIn = item.store.unitsSoldIn || 0; + const sellOutUnits = Math.max(0, sellIn - inv); + const valSellOut = sellOutUnits * price; + + totalSellOutUnits += sellOutUnits; + totalSellOutValue += valSellOut; + }); + + return { + totalStores, + totalResponses, + visitedStores, + pendingStores, + complianceRate, + oosStores, + availableStores, + oosRate, + numericDistribution, + totalPhysicalInventory, + totalUnitsSoldIn, + totalSellOutUnits, + totalSellOutValue, + avgRetailPrice, + avgIceKickFacings, + avgBrandShelfShare, + avgCategoryShelfShare, + mainShelfStores, + secondaryDisplayStores, + gondolaEndStores, + additionalExhibitionStores, + mainShelfRate, + secondaryDisplayRate, + gondolaEndRate, + additionalExhibitionRate + }; + }, [filteredMergedData, dataResult]); + + if (authLoading) { + return ( +
+
+
+ +
+ +
+
+ ); + } + + if (!authSession || recoverySession || recoveryError) { + return ( + { + setAuthSession(session); + setRecoverySession(null); + setRecoveryError(''); + setDataResult(null); + }} + onRecoveryComplete={() => { + clearAuthActionFromUrl(); + setRecoverySession(null); + setRecoveryError(''); + setAuthSession(null); + setDataResult(null); + }} + /> + ); + } + + const allStores = dataResult?.stores || []; + + const handleLogout = async () => { + await signOut(authSession); + setAuthSession(null); + setDataResult(null); + setRecoverySession(null); + setRecoveryError(''); + }; + + return ( +
+ + {/* Header */} +
setIsTutorialOpen(true)} + userEmail={authSession.user.email || 'Signed in'} + onLogout={handleLogout} + /> + +
+ + {/* Error notification if sync fails */} + {dataResult?.error && ( +
+
+ + {dataResult.error} +
+ +
+ )} + + {/* Global Filter Bar */} + + + {/* KPI Cards Grid */} + + + {/* Audit Step Tabs Navigation */} +
+ + + + + + + + + + + +
+ )} +
+ + {/* Zone Breakdown Table */} +
+

OOS Availability by Geographic Zone

+
+ {Array.from(zoneStats.entries()).map(([zone, stat]) => { + const availPct = stat.total > 0 ? (stat.available / stat.total) * 100 : 0; + return ( +
+
+ {zone} + {stat.total} audited +
+
+ {stat.available} avail. + {stat.oos} OOS +
+
+
+
+
+ ); + })} +
+
+ +
+ ); +}; + diff --git a/src/components/AuditStep2Exhibicion.tsx b/src/components/AuditStep2Exhibicion.tsx new file mode 100644 index 0000000..79a9188 --- /dev/null +++ b/src/components/AuditStep2Exhibicion.tsx @@ -0,0 +1,503 @@ +import React, { useState } from 'react'; +import { MergedStoreAudit } from '../types'; +import { Image as ImageIcon, ChevronLeft, ChevronRight, X, ExternalLink, Layers, Tag } from 'lucide-react'; + +interface AuditStep2Props { + mergedData: MergedStoreAudit[]; + onSelectStore: (item: MergedStoreAudit) => void; +} + +export const AuditStep2Exhibicion: React.FC = ({ + mergedData, + onSelectStore +}) => { + const visitedStores = mergedData.filter(m => m.status === 'Visited'); + const visitedAvailable = mergedData.filter(m => m.status === 'Visited' && m.response?.isAvailable === 'Yes'); + + // Store selection filter state + const [selectedStoreFilter, setSelectedStoreFilter] = useState(null); + + // Filter stores according to active store selection if clicked + const activeVisitedStores = selectedStoreFilter + ? visitedStores.filter(item => item.store.customer === selectedStoreFilter) + : visitedStores; + + const activeVisitedAvailable = selectedStoreFilter + ? visitedAvailable.filter(item => item.store.customer === selectedStoreFilter) + : visitedAvailable; + + // Placement breakdown across reported/visited stores + let mainShelfCount = 0; + let secondaryDisplayCount = 0; + let endcapCount = 0; + + activeVisitedStores.forEach(item => { + const loc = (item.response?.placementLocation || '').toLowerCase(); + if (loc.includes('estante') || loc.includes('main') || loc.includes('shelf')) mainShelfCount++; + if (loc.includes('exhibici') || loc.includes('display') || loc.includes('secondary') || loc.includes('secundaria')) secondaryDisplayCount++; + if (loc.includes('cabecera') || loc.includes('endcap') || loc.includes('gondola') || loc.includes('g贸ndola') || loc.includes('end')) endcapCount++; + }); + + const totalLocs = activeVisitedStores.length || 1; + + // Helper to extract URLs + const extractUrls = (list: string[] | undefined): string[] => { + if (!list) return []; + return list.flatMap(s => s.split(/[\r\n,;\s]+/).map(url => url.trim())).filter(url => url.startsWith('http://') || url.startsWith('https://')); + }; + + // 1. Shelf / Display Photo Gallery + const shelfPhotoGallery = activeVisitedStores.flatMap(item => { + const urls = extractUrls(item.response?.shelfPhotos); + return urls.map(url => ({ + url, + store: item.store.customer, + type: item.response?.placementLocation || 'Display / Shelf', + date: item.response?.submissionDate || '', + auditItem: item + })); + }); + + // 2. POP Material Photo Gallery + const popPhotoGallery = activeVisitedStores.flatMap(item => { + const urls = extractUrls(item.response?.popPhotos); + return urls.map(url => ({ + url, + store: item.store.customer, + type: item.response?.popVisible || 'POP Material', + date: item.response?.submissionDate || '', + auditItem: item + })); + }); + + // Pagination States for Both Galleries + const [shelfCurrentPage, setShelfCurrentPage] = useState(1); + const [popCurrentPage, setPopCurrentPage] = useState(1); + const ITEMS_PER_PAGE = 6; // 3x2 grid per gallery + + const shelfTotalPages = Math.ceil(shelfPhotoGallery.length / ITEMS_PER_PAGE) || 1; + const paginatedShelfPhotos = shelfPhotoGallery.slice((shelfCurrentPage - 1) * ITEMS_PER_PAGE, shelfCurrentPage * ITEMS_PER_PAGE); + + const popTotalPages = Math.ceil(popPhotoGallery.length / ITEMS_PER_PAGE) || 1; + const paginatedPopPhotos = popPhotoGallery.slice((popCurrentPage - 1) * ITEMS_PER_PAGE, popCurrentPage * ITEMS_PER_PAGE); + + // Active Photo Lightbox + const [activePhoto, setActivePhoto] = useState<{ url: string; store: string; type: string; date: string; auditItem: MergedStoreAudit } | null>(null); + + const handleRowClick = (customerName: string) => { + if (selectedStoreFilter === customerName) { + setSelectedStoreFilter(null); + } else { + setSelectedStoreFilter(customerName); + } + setShelfCurrentPage(1); + setPopCurrentPage(1); + }; + + return ( +
+ + {/* Header Info */} +
+
+
+
+ + Step 2 + +

Placement & Display (Store Mapping)

+
+

+ Assessment of Share of Display, physical location, and execution of POP materials in active stores. +

+
+
+
{visitedAvailable.length}
+
Stores with Display
+
+
+
+ + {/* Placement Distribution */} +
+ + {/* Main Shelf */} +
+
+ + Main Shelf + + + {((mainShelfCount / totalLocs) * 100).toFixed(0)}% + +
+
{mainShelfCount} stores
+

Standard placement in beverage section

+
+
+
+
+ + {/* Secondary Display */} +
+
+ + Secondary Display + + + {((secondaryDisplayCount / totalLocs) * 100).toFixed(0)}% + +
+
{secondaryDisplayCount} stores
+

Promotional island, auxiliary cooler, dump bin

+
+
+
+
+ + {/* Endcap */} +
+
+ + Endcap + + + {((endcapCount / totalLocs) * 100).toFixed(0)}% + +
+
{endcapCount} stores
+

High-visibility endcap placement

+
+
+
+
+ +
+ + {/* Main Layout: Table on Left + Two Stacked Galleries on Right */} +
+ + {/* Left Column: Store Mapping Table (7 cols) */} +
+
+
+
+

+ Display & Promotional Material (POP) Mapping +

+ {selectedStoreFilter && ( + + Filtered: {selectedStoreFilter} + + + )} +
+

+ Click a store row to filter photo galleries and metrics. +

+
+ + {selectedStoreFilter && ( + + )} +
+ +
+ + + + + + + + + + + {visitedAvailable.map(item => { + const isSelected = selectedStoreFilter === item.store.customer; + + return ( + handleRowClick(item.store.customer)} + className={`cursor-pointer transition-all ${ + isSelected + ? 'bg-amber-100/70 border-l-4 border-l-amber-500 font-bold text-slate-950' + : 'hover:bg-sky-50/60' + }`} + > + + + + + + ); + })} + +
Store / CustomerZoneProduct LocationPOP Material
+ {item.store.customer} + {isSelected && ( + + Filtered + + )} + {item.store.zone} + + {item.response?.placementLocation || 'No Location'} + + + {item.response?.popVisible || 'None'} +
+
+
+ + {/* Right Column: Two Stacked Galleries (5 cols) */} +
+ + {/* Top Gallery: Display & Shelf Photos */} +
+
+
+
+

+ + Display & Shelf Photos +

+

+ Shelf & display photos from floor audit +

+
+ + {shelfPhotoGallery.length} photo(s) + +
+ + {shelfPhotoGallery.length === 0 ? ( +
+ No shelf photos attached for this selection. +
+ ) : ( + /* 3-Column Image Thumbnail Grid */ +
+ {paginatedShelfPhotos.map((p, idx) => ( +
setActivePhoto(p)} + className="group relative rounded-xl border border-slate-200/80 bg-slate-900 overflow-hidden cursor-pointer aspect-[4/3] shadow-2xs hover:shadow-md hover:border-sky-500 transition-all flex flex-col justify-end" + > + {p.store} +
+
+

+ {p.store} +

+
+
+ ))} +
+ )} +
+ + {/* Pagination Controls for Shelf Photos */} + {shelfPhotoGallery.length > 0 && ( +
+ + + + Page {shelfCurrentPage} of {shelfTotalPages} + + + +
+ )} +
+ + {/* Bottom Gallery: POP Material Photos */} +
+
+
+
+

+ + POP Material Photos +

+

+ Promotional POP photos (posters, wobblers, shelf strips) +

+
+ + {popPhotoGallery.length} photo(s) + +
+ + {popPhotoGallery.length === 0 ? ( +
+ No POP material photos attached for this selection. +
+ ) : ( + /* 3-Column Image Thumbnail Grid */ +
+ {paginatedPopPhotos.map((p, idx) => ( +
setActivePhoto(p)} + className="group relative rounded-xl border border-slate-200/80 bg-slate-900 overflow-hidden cursor-pointer aspect-[4/3] shadow-2xs hover:shadow-md hover:border-amber-500 transition-all flex flex-col justify-end" + > + {p.store} +
+
+

+ {p.store} +

+
+
+ ))} +
+ )} +
+ + {/* Pagination Controls for POP Photos */} + {popPhotoGallery.length > 0 && ( +
+ + + + Page {popCurrentPage} of {popTotalPages} + + + +
+ )} +
+ +
+ +
+ + {/* Lightbox Modal */} + {activePhoto && ( +
setActivePhoto(null)} + className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-xs flex items-center justify-center p-4 cursor-pointer" + > +
e.stopPropagation()} + className="relative max-w-3xl w-full bg-white border border-sky-100 rounded-3xl overflow-hidden p-4 shadow-2xl space-y-3 cursor-default" + > +
+
+

{activePhoto.store}

+

{activePhoto.type} 鈥 {activePhoto.date}

+
+
+ + + + +
+
+ +
+ {activePhoto.store} +
+ +
+ + +
+
+
+ )} + +
+ ); +}; + + + diff --git a/src/components/AuditStep3Estante.tsx b/src/components/AuditStep3Estante.tsx new file mode 100644 index 0000000..8436b69 --- /dev/null +++ b/src/components/AuditStep3Estante.tsx @@ -0,0 +1,263 @@ +import React from 'react'; +import { MergedStoreAudit } from '../types'; +import { Tag, BarChart2, Layers, Repeat, ArrowDownRight, Award } from 'lucide-react'; + +interface AuditStep3Props { + mergedData: MergedStoreAudit[]; + onSelectStore: (item: MergedStoreAudit) => void; +} + +export const AuditStep3Estante: React.FC = ({ + mergedData, + onSelectStore +}) => { + const visitedAvailable = mergedData.filter(m => m.status === 'Visited' && m.response?.isAvailable === 'Yes'); + + // Calculating averages + let totalIceKickFacings = 0; + let totalLucozadeFacings = 0; + let totalCategoryFacings = 0; + let totalPrice = 0; + let priceCount = 0; + + visitedAvailable.forEach(item => { + const res = item.response; + if (res) { + totalIceKickFacings += res.facingsIceKick || 0; + totalLucozadeFacings += res.facingsLucozadeBrand || 0; + totalCategoryFacings += res.facingsCategoryTotal || 0; + if (res.retailPrice && res.retailPrice > 0) { + totalPrice += res.retailPrice; + priceCount++; + } + } + }); + + const avgIceKickFacings = visitedAvailable.length > 0 ? totalIceKickFacings / visitedAvailable.length : 0; + const avgLucozadeFacings = visitedAvailable.length > 0 ? totalLucozadeFacings / visitedAvailable.length : 0; + const avgCategoryFacings = visitedAvailable.length > 0 ? totalCategoryFacings / visitedAvailable.length : 0; + const avgPrice = priceCount > 0 ? totalPrice / priceCount : 0; + + // Overall Shares + const brandShare = totalLucozadeFacings > 0 ? (totalIceKickFacings / totalLucozadeFacings) * 100 : 0; + const categoryShare = totalCategoryFacings > 0 ? (totalLucozadeFacings / totalCategoryFacings) * 100 : 0; + const iceKickCategoryShare = totalCategoryFacings > 0 ? (totalIceKickFacings / totalCategoryFacings) * 100 : 0; + + // SKU Substitution Analysis + const substitutionList = visitedAvailable + .map(i => ({ + store: i.store.customer, + sub: i.response?.skuSubstitution || 'N/A', + iceKickFacings: i.response?.facingsIceKick || 0, + price: i.response?.retailPrice || 0 + })) + .filter(i => i.sub && !i.sub.toLowerCase().includes('ninguna') && !i.sub.toLowerCase().includes('n/a')); + + return ( +
+ + {/* Header Info */} +
+
+
+
+ + Step 3 + +

Main Shelf Measurement (Detailed Analysis)

+
+

+ Detailed audit of consumer price, facing counts, Share of Brand Shelf, Share of Category, and competitor SKU substitution. +

+
+
+
+
${avgPrice.toFixed(2)}
+
Avg Price
+
+
+
{brandShare.toFixed(1)}%
+
Brand Share
+
+
+
+
+ + {/* Facing & Share Metric Cards */} +
+ + {/* Ice Kick Facings */} +
+
+ + Ice Kick Facings + + + + +
+
+ {avgIceKickFacings.toFixed(1)} facings/store +
+

Total accum: {totalIceKickFacings} facings

+
+ + {/* Share of Brand Shelf */} +
+
+ + Share of Brand Shelf + + + + +
+
+ {brandShare.toFixed(1)}% +
+

+ Ice Kick / Total Lucozade Brand ({totalLucozadeFacings} facings) +

+
+ + {/* Share of Category Shelf */} +
+
+ + Share of Category + + + + +
+
+ {categoryShare.toFixed(1)}% +
+

+ Lucozade / Total Category ({totalCategoryFacings} facings) +

+
+ + {/* Retail Price */} +
+
+ + Avg Retail Price + + + + +
+
+ ${avgPrice.toFixed(2)} +
+

Main shelf retail price

+
+ +
+ + {/* Share of Shelf Breakdown Bar */} +
+

+ Energy & Sports Drinks Shelf Composition +

+

+ Total facing distribution across Lucozade Ice Kick, rest of Lucozade, and competitors. +

+ +
+
+
+ Lucozade Ice Kick ({totalIceKickFacings} facings) + {iceKickCategoryShare.toFixed(1)}% of category +
+
+
+
+
+ +
+
+ Total Lucozade Brand ({totalLucozadeFacings} facings) + {categoryShare.toFixed(1)}% of category +
+
+
+
+
+ +
+
+ Competitors / Total Category ({totalCategoryFacings} facings) + 100% of shelf +
+
+
+
+
+
+
+ + {/* SKU Substitution Tracking Table */} +
+
+
+

+ + SKU Substitution Tracking (Space Gain) +

+

+ Which competitor brands or SKUs surrendered shelf space for Ice Kick placement? +

+
+ + {substitutionList.length} space gain(s) recorded + +
+ +
+ + + + + + + + + + + + {visitedAvailable.map(item => ( + + + + + + + + ))} + +
Store / CustomerSurrendered Brand / SKUFacings Gained by Ice KickShelf PriceView Report
{item.store.customer} + + + {item.response?.skuSubstitution || 'N/A'} + + + +{item.response?.facingsIceKick || 0} facings + + ${(item.response?.retailPrice || 0).toFixed(2)} + + +
+
+
+ +
+ ); +}; + diff --git a/src/components/AuditStep4Inventario.tsx b/src/components/AuditStep4Inventario.tsx new file mode 100644 index 0000000..8d78dc9 --- /dev/null +++ b/src/components/AuditStep4Inventario.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { MergedStoreAudit } from '../types'; +import { PackageCheck, DollarSign, Truck, ShoppingBag } from 'lucide-react'; + +interface AuditStep4Props { + mergedData: MergedStoreAudit[]; + onSelectStore: (item: MergedStoreAudit) => void; +} + +export const AuditStep4Inventario: React.FC = ({ + mergedData, + onSelectStore +}) => { + const visitedItems = mergedData.filter(m => m.status === 'Visited'); + + let totalPhysicalInventory = 0; + let totalSellInUnits = 0; + let totalSellOutUnits = 0; + let totalSellOutValue = 0; + + visitedItems.forEach(item => { + const inv = item.response?.totalPhysicalInventory || 0; + const price = item.response?.retailPrice || 0; + const sellIn = item.store.unitsSoldIn || 0; + const sellOutUnits = Math.max(0, sellIn - inv); + const valSellOut = sellOutUnits * price; + + totalPhysicalInventory += inv; + totalSellInUnits += sellIn; + totalSellOutUnits += sellOutUnits; + totalSellOutValue += valSellOut; + }); + + return ( +
+ + {/* Header Info */} +
+
+
+
+ + Step 4 + +

Inventory & Closing (Store Manager Alignment)

+
+

+ Final physical count combining sales floor units and backroom stock. + Volume Sell-Out is calculated by subtracting physical inventory from dispatched Sell-In. +

+
+
+
+
{totalPhysicalInventory.toLocaleString()}
+
Physical Inventory
+
+
+
{totalSellOutUnits.toLocaleString()}
+
Volume Sell-Out (Units)
+
+
+
${totalSellOutValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
Value Sell-Out ($)
+
+
+
+
+ + {/* Summary Cards Grid */} +
+ + {/* 1. Physical Inventory */} +
+
+ + Physical Inventory (Floor + Backroom) + +
+ +
+
+
+ {totalPhysicalInventory.toLocaleString()} units +
+

+ Physical count reported by auditor in Step 4 +

+
+ + {/* 2. Dispatched Sell-In */} +
+
+ + Dispatched Sell-In (Units Sold In) + +
+ +
+
+
+ {totalSellInUnits.toLocaleString()} units +
+

+ Provided by the tiendas sheet +

+
+ + {/* 3. Calculated Volume Sell-Out */} +
+
+ + Volume Sell-Out (Units) + +
+ +
+
+
+ {totalSellOutUnits.toLocaleString()} units +
+

+ Calculated (Dispatched Sell-In - Physical Inv) +

+
+ + {/* 4. Estimated Value Sell-Out */} +
+
+ + Estimated Value Sell-Out ($) + +
+ +
+
+
+ ${totalSellOutValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+

+ Calculated (Volume Sell-Out * Shelf Price) +

+
+ +
+ + {/* Detailed Inventory & Sell-Out Table */} +
+
+
+

+ Physical Inventory and Sell-Out by Store +

+

+ Relationship between dispatched Sell-In, physical inventory, and calculated Sell-Out (Units and Value). +

+
+
+ +
+ + + + + + + + + + + + + + + {visitedItems.map(item => { + const inv = item.response?.totalPhysicalInventory || 0; + const price = item.response?.retailPrice || 0; + const sellIn = item.store.unitsSoldIn || 0; + const sellOutUnits = Math.max(0, sellIn - inv); + const valSellOut = sellOutUnits * price; + + return ( + + + + + + + + + + + ); + })} + +
Store / CustomerZone / ChannelSell-In (Units Sold In)Total Physical Inv (Floor + Backroom)Sell-Out (Units)Shelf PriceValue Sell-Out ($)View Report
{item.store.customer}{item.store.zone} - {item.store.customerChannel}{sellIn.toLocaleString()} units + {inv.toLocaleString()} units + {item.hasOOS && inv > 0 && ( + 鈿狅笍 In Backroom (OOS on shelf) + )} + + {sellOutUnits.toLocaleString()} units + ${price.toFixed(2)}${valSellOut.toFixed(2)} + +
+
+
+ +
+ ); +}; + + diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx new file mode 100644 index 0000000..accee73 --- /dev/null +++ b/src/components/AuthScreen.tsx @@ -0,0 +1,440 @@ +import React, { FormEvent, useState } from 'react'; +import { + ArrowLeft, + CheckCircle2, + Eye, + EyeOff, + LoaderCircle, + LockKeyhole, + Mail, + UserRound, + Zap +} from 'lucide-react'; +import { + AuthSession, + RecoverySession, + sendPasswordReset, + signInWithPassword, + signUpWithPassword, + updatePasswordFromRecovery, + getRememberedEmail, + setRememberedEmail +} from '../services/supabaseAuth'; + +type AuthMode = 'login' | 'register' | 'forgot'; + +interface AuthScreenProps { + recoverySession?: RecoverySession | null; + recoveryError?: string; + onAuthenticated: (session: AuthSession) => void; + onRecoveryComplete: () => void; +} + +function friendlyError(message: string): string { + const normalized = message.toLowerCase(); + if (normalized.includes('invalid login credentials') || normalized.includes('invalid authentication credentials')) return 'Incorrect email or password.'; + if (normalized.includes('email not confirmed')) return 'Please confirm your email before signing in.'; + if (normalized.includes('not authorized to access')) return 'This account does not have access to this application.'; + if (normalized.includes('verify access')) return 'Access could not be verified. Please try again.'; + if (normalized.includes('user already registered') || normalized.includes('account already exists')) return 'An account already exists for this email.'; + if (normalized.includes('password should be')) return 'Use a password with at least 8 characters.'; + if (normalized.includes('rate limit') || normalized.includes('too many attempts')) return 'Too many attempts. Please wait 15 minutes and try again.'; + return message; +} + +export const AuthScreen: React.FC = ({ + recoverySession = null, + recoveryError = '', + onAuthenticated, + onRecoveryComplete +}) => { + const recoveryMode = Boolean(recoverySession); + const [mode, setMode] = useState('login'); + const rememberedEmail = getRememberedEmail(); + const [fullName, setFullName] = useState(''); + const [email, setEmail] = useState(rememberedEmail); + const [rememberMe, setRememberMe] = useState(Boolean(rememberedEmail)); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(recoveryError); + const [success, setSuccess] = useState(''); + + const resetMessages = () => { + setError(''); + setSuccess(''); + }; + + const switchMode = (nextMode: AuthMode) => { + setMode(nextMode); + setPassword(''); + setConfirmPassword(''); + resetMessages(); + }; + + const handleLogin = async (event: FormEvent) => { + event.preventDefault(); + resetMessages(); + setLoading(true); + try { + const session = await signInWithPassword(email.trim(), password, rememberMe); + onAuthenticated(session); + } catch (err: any) { + setError(friendlyError(err?.message || 'Unable to sign in.')); + } finally { + setLoading(false); + } + }; + + const handleRegister = async (event: FormEvent) => { + event.preventDefault(); + resetMessages(); + + if (password.length < 8) { + setError('Use a password with at least 8 characters.'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + setLoading(true); + try { + const result = await signUpWithPassword(fullName.trim(), email.trim(), password); + if (result.session) { + onAuthenticated(result.session); + } else { + setMode('login'); + setSuccess(result.message); + setPassword(''); + setConfirmPassword(''); + } + } catch (err: any) { + setError(friendlyError(err?.message || 'Unable to create the account.')); + } finally { + setLoading(false); + } + }; + + const handleForgot = async (event: FormEvent) => { + event.preventDefault(); + resetMessages(); + setLoading(true); + try { + const message = await sendPasswordReset(email.trim()); + setSuccess(message); + } catch (err: any) { + setError(friendlyError(err?.message || 'Unable to send the recovery email.')); + } finally { + setLoading(false); + } + }; + + const handleUpdatePassword = async (event: FormEvent) => { + event.preventDefault(); + resetMessages(); + + if (!recoverySession) { + setError('The recovery link is invalid or has expired.'); + return; + } + if (password.length < 8) { + setError('Use a password with at least 8 characters.'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + setLoading(true); + try { + await updatePasswordFromRecovery(recoverySession, password); + setSuccess('Password updated successfully. You can now sign in.'); + setPassword(''); + setConfirmPassword(''); + setTimeout(() => onRecoveryComplete(), 900); + } catch (err: any) { + setError(friendlyError(err?.message || 'Unable to update the password.')); + } finally { + setLoading(false); + } + }; + + const isRegister = mode === 'register'; + const isForgot = mode === 'forgot'; + const title = recoveryMode + ? 'Create a new password' + : isRegister + ? 'Create your account' + : isForgot + ? 'Recover your password' + : 'Welcome back'; + const subtitle = recoveryMode + ? 'Enter a new secure password for your account.' + : isRegister + ? 'Register to access the audit dashboard.' + : isForgot + ? 'We will send a secure recovery link to your email.' + : 'Sign in to access the audit dashboard.'; + + return ( +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
Ice Kick
+
Lucozade Store Audit
+
+
+ + {!recoveryMode && !isForgot && ( +
+ + +
+ )} + + {(isForgot || recoveryMode) && ( + + )} + +
+

{title}

+

{subtitle}

+
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + {recoveryMode ? ( +
+ setShowPassword(value => !value)} + autoComplete="new-password" + /> + setShowPassword(value => !value)} + autoComplete="new-password" + /> + + + ) : isForgot ? ( +
+ + + + ) : isRegister ? ( +
+ } + /> + + setShowPassword(value => !value)} + autoComplete="new-password" + /> + setShowPassword(value => !value)} + autoComplete="new-password" + /> + + + ) : ( +
+ + setShowPassword(value => !value)} + autoComplete="current-password" + /> +
+ + +
+ + + )} +
+
+

Secure access powered by encrypted authentication.

+
+
+ ); +}; + +interface FieldProps { + label: string; + value: string; + onChange: (value: string) => void; + type: string; + autoComplete: string; + icon: React.ReactNode; +} + +const Field: React.FC = ({ label, value, onChange, type, autoComplete, icon }) => ( + +); + +const EmailField: React.FC<{ value: string; onChange: (value: string) => void }> = ({ value, onChange }) => ( + } + /> +); + +interface PasswordFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + show: boolean; + onToggle: () => void; + autoComplete: string; +} + +const PasswordField: React.FC = ({ + label, + value, + onChange, + show, + onToggle, + autoComplete +}) => ( + +); + +const SubmitButton: React.FC<{ loading: boolean; label: string }> = ({ loading, label }) => ( + +); diff --git a/src/components/ClientTutorialModal.tsx b/src/components/ClientTutorialModal.tsx new file mode 100644 index 0000000..6176e66 --- /dev/null +++ b/src/components/ClientTutorialModal.tsx @@ -0,0 +1,586 @@ +import React, { useState, useEffect } from 'react'; +import { + X, + ChevronLeft, + ChevronRight, + Presentation, + CheckCircle2, + Filter, + BarChart3, + Layers, + Database, + RefreshCw, + Download, + Eye, + PackageCheck, + Zap, + Sparkles, + ArrowRight, + Sliders, + FileSpreadsheet +} from 'lucide-react'; + +interface ClientTutorialModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const ClientTutorialModal: React.FC = ({ + isOpen, + onClose +}) => { + const [currentSlide, setCurrentSlide] = useState(0); + + const totalSlides = 7; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + if (e.key === 'ArrowRight' || e.key === 'Space') { + setCurrentSlide(prev => Math.min(prev + 1, totalSlides - 1)); + } else if (e.key === 'ArrowLeft') { + setCurrentSlide(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, totalSlides, onClose]); + + if (!isOpen) return null; + + const slides = [ + // Slide 1: Introduction / Overview + { + title: "Store Audit Dashboard Overview", + subtitle: "Executive POS Monitoring Platform: Lucozade Sport Ice Kick", + badge: "Module 1: Overview", + icon: , + content: ( +
+
+
+ +
+
+

What is the Store Audit Dashboard?

+

+ An executive, real-time management platform designed to audit point-of-sale (POS) commercial execution for Lucozade Sport Ice Kick, tracking stock availability (OOS), shelf share, POP promotional material, and estimated Sell-Out sales. +

+
+
+ +
+
+
+ + + +
1. Master Store List
+
+

+ Contains the official universe of registered stores, customer coding, channel type, geographic zone, and warehouse shipments (Sell-In). +

+
+ +
+
+ + + +
2. Field Audit Responses
+
+

+ Live field data captured directly by auditors via Google Form: retail shelf prices, OOS status, facings, POP placement, and shelf photo proof. +

+
+
+ +
+
+ +
+
+
Automated Real-Time Synchronization
+

+ Whenever a field auditor submits a store audit, data automatically syncs to this live dashboard without requiring manual consolidation or uploads. +

+
+
+
+ ) + }, + + // Slide 2: Key Metrics + { + title: "Key Performance Indicators (KPIs)", + subtitle: "Executive Metrics & Commercial Performance Tracker", + badge: "Module 2: Key Metrics", + icon: , + content: ( +
+

+ The top summary panel consolidates 11 executive metrics updated dynamically based on active filters: +

+ +
+
+
Store Audit Coverage
+
% Coverage
+

+ Percentage of visited stores against total assigned target. +

+
+ +
+
Out Of Stock (OOS)
+
% OOS Rate
+

+ Stores where Lucozade Ice Kick is out of stock on the shelf. +

+
+ +
+
% Add. Exhibition
+
Extra Display %
+

+ Presence across secondary displays and gondola end caps. +

+
+ +
+
Lucozade Share
+
Facing Share %
+

+ Ice Kick facings share relative to total Lucozade brand. +

+
+ +
+
Volume Sell-Out
+
Units Sold
+

+ Rotation calculation: Sell-In - Physical Inventory. +

+
+ +
+
Value Sell-Out
+
Sales Value ($)
+

+ Monetary valuation: Sell-Out Units * Shelf Price. +

+
+
+
+ ) + }, + + // Slide 3: Smart Filters + { + title: "Smart Filtering System", + subtitle: "Dynamic Segmentation by Region, Channel, & Audit Status", + badge: "Module 3: Filters", + icon: , + content: ( +
+
+
+ + Multi-Criteria Segmentation +
+ + Combinable Filters + +
+ +
+
+
+ 1 +
+
+ Geographic Region (Zone) +

+ Filter by country zones (e.g. West, South, Central, East, North) to measure regional performance. +

+
+
+ +
+
+ 2 +
+
+ Sales Channel (Channel) +

+ Segmentation by customer type: Supermarkets, Service Stations (Petrol/Convenience), Wholesale, etc. +

+
+
+ +
+
+ 3 +
+
+ Audit Status & OOS Availability +

+ Compare Visited vs Pending stores, and immediately pinpoint PDVs with active Out of Stock alerts. +

+
+
+ +
+
+ 4 +
+
+ Direct Search by Store Code or Name +

+ Use the instant search bar to find any specific customer code or store name. +

+
+
+
+
+ ) + }, + + // Slide 4: Steps 1 & 2 + { + title: "Audit Methodology (Steps 1 & 2)", + subtitle: "Presence Verification, OOS, Location Mapping & POP Material", + badge: "Module 4: Steps 1 & 2", + icon: , + content: ( +
+
+ {/* Step 1 */} +
+
+ + STEP 1 + +
Presence & OOS Availability
+
+

+ First in-store check. Confirms whether Lucozade Sport Ice Kick is available for purchase. +

+
    +
  • Immediate detection of stockouts (OOS).
  • +
  • Identification of non-availability causes.
  • +
  • Mapping of effective numerical distribution.
  • +
+
+ + {/* Step 2 */} +
+
+ + STEP 2 + +
Placement & POP Material
+
+

+ Evaluation of product visibility and promotional support at the point of sale. +

+
    +
  • Placement: Main Shelf, Chiller, Secondary Display, Gondola End.
  • +
  • POP Material check: Wobblers, Shelf Talkers, Posters, Branded Coolers.
  • +
  • Visual merchandising compliance audit.
  • +
+
+
+ +
+ + Dedicated tabs in the top navigation bar allow you to inspect each step individually. +
+
+ ) + }, + + // Slide 5: Steps 3 & 4 + { + title: "Audit Methodology (Steps 3 & 4)", + subtitle: "Main Shelf Measurement, Facings, Inventory & Sell-Out", + badge: "Module 5: Steps 3 & 4", + icon: , + content: ( +
+
+ {/* Step 3 */} +
+
+ + STEP 3 + +
Main Shelf, Facings & Price
+
+

+ Quantification of physical shelf space and verification of retail consumer price. +

+
    +
  • Count of Ice Kick facings vs Lucozade Brand vs Category.
  • +
  • Real-time Share of Shelf (%) calculation.
  • +
  • SKU substitution check when Ice Kick is missing.
  • +
  • Shelf retail price ($) recording.
  • +
+
+ + {/* Step 4 */} +
+
+ + STEP 4 + +
Physical Inventory & Sell-Out
+
+

+ Total store unit count and inferred Sell-Out volume/value. +

+
    +
  • Sum of sales floor inventory + backroom warehouse stock.
  • +
  • Sell-Out Units Formula: Sell-In - Physical Inventory.
  • +
  • Sell-Out Value ($) Formula: Units * Shelf Price.
  • +
+
+
+ +
+
+ Precise Inventory Control: Enables real sales velocity measurement and replenishment planning before stockouts occur. +
+
+
+ ) + }, + + // Slide 6: Master Directory & CSV Export + { + title: "Consolidated Directory & CSV Export", + subtitle: "Complete Master Table & Individual Store Audit Reports", + badge: "Module 6: Data & Export", + icon: , + content: ( +
+
+
+
+ +
+
14 Master Columns
+

+ Comprehensive comparative table detailing POS, Zone, Channel, Status, OOS, Placement, POP, Price, Facings, Inventory, Sell-In, and Sell-Out. +

+
+ +
+
+ +
+
Individual Store Report (View)
+

+ Click Report to open the complete store dossier with form photo evidence and step-by-step breakdown. +

+
+ +
+
+ +
+
Clean CSV Export
+

+ Download optimized Excel/Google Sheets reports with UTF-8 BOM encoding to prevent misaligned rows. +

+
+
+ +
+ + Clicking on table headers (e.g., Sell-Out, Price, Zone) instantly sorts the store directory ascending or descending. +
+
+ ) + }, + + // Slide 7: Client Workflow + { + title: "Recommended Client Workflow", + subtitle: "Best Practices for Daily / Weekly Management", + badge: "Module 7: Client Use Case", + icon: , + content: ( +
+

+ Recommended steps to maximize commercial value from the audit dashboard: +

+ +
+
+
+
1. Initial Coverage Verification
+

+ Review the Store Audit Coverage metric to track field team progress along planned routes. +

+
+ +
+
+
2. Priority Out-of-Stock (OOS) Resolution
+

+ Filter by OOS Status = Out of Stock to trigger emergency replenishments for key stores. +

+
+ +
+
+
3. Merchandising & Display Optimization
+

+ Analyze Step 2 and Step 3 tabs to negotiate extra shelf space or POP placement in low-share stores. +

+
+ +
+
+
4. Management Reporting & Export
+

+ Export the consolidated CSV report for weekly performance reviews and client presentations. +

+
+
+ +
+ +
+
+ ) + } + ]; + + return ( +
+
+ + {/* Slide Deck Header Bar */} +
+
+
+ +
+
+
+ + {slides[currentSlide].badge} + + + Slide {currentSlide + 1} of {totalSlides} + +
+

+ Client Dashboard User Guide +

+
+
+ + +
+ + {/* Slide Progress Indicator Bar */} +
+ {slides.map((_, idx) => ( +
setCurrentSlide(idx)} + className={`h-full flex-1 transition-all cursor-pointer ${ + idx === currentSlide + ? 'bg-amber-300' + : idx < currentSlide + ? 'bg-sky-400' + : 'bg-slate-200' + }`} + /> + ))} +
+ + {/* Main Slide Content Area */} +
+
+ {/* Slide Title Header */} +
+
+ {slides[currentSlide].icon} +
+
+

+ {slides[currentSlide].title} +

+

+ {slides[currentSlide].subtitle} +

+
+
+ + {/* Slide Specific Content */} + {slides[currentSlide].content} +
+
+ + {/* Slide Footer Controls Bar */} +
+ + + {/* Dots Indicator */} +
+ {slides.map((_, idx) => ( +
+ + +
+ +
+
+ ); +}; diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx new file mode 100644 index 0000000..cc32932 --- /dev/null +++ b/src/components/FilterBar.tsx @@ -0,0 +1,358 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { FilterState, Store } from '../types'; +import { WeekOption } from '../utils/dateUtils'; +import { Search, Filter, RotateCcw, Store as StoreIcon, ChevronDown, Check, X, Building2, Calendar } from 'lucide-react'; + +interface FilterBarProps { + filters: FilterState; + stores: Store[]; + weekOptions: WeekOption[]; + onChange: (newFilters: FilterState) => void; + onReset: () => void; +} + +interface SearchableStoreSelectProps { + stores: Store[]; + selectedCustomer: string; + onSelectCustomer: (customerName: string) => void; +} + +const SearchableStoreSelect: React.FC = ({ + stores, + selectedCustomer, + onSelectCustomer +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Deduplicate stores strictly by Customer Name so each store name appears ONLY ONCE + const uniqueStores = useMemo(() => { + const map = new Map(); + stores.forEach(s => { + if (!s.customer || !s.customer.trim()) return; + const key = s.customer.trim().toLowerCase(); + if (!map.has(key)) { + map.set(key, s); + } + }); + return Array.from(map.values()).sort((a, b) => a.customer.localeCompare(b.customer)); + }, [stores]); + + const filteredStores = useMemo(() => { + if (!searchTerm.trim()) return uniqueStores; + const q = searchTerm.toLowerCase().trim(); + return uniqueStores.filter(s => + s.customer.toLowerCase().includes(q) || + s.customerCode.toLowerCase().includes(q) || + s.zone.toLowerCase().includes(q) || + s.address.toLowerCase().includes(q) + ); + }, [uniqueStores, searchTerm]); + + return ( +
+ + + {/* Trigger Button */} +
+ + + {/* Dropdown Menu */} + {isOpen && ( +
+ {/* Search Input inside Dropdown */} +
+ + setSearchTerm(e.target.value)} + placeholder="Search store name, code, zone..." + autoFocus + className="w-full pl-8 pr-7 py-1.5 text-xs bg-sky-50/60 border border-sky-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 text-slate-900 font-bold placeholder-slate-400" + /> + {searchTerm && ( + + )} +
+ + {/* List Options */} +
+ {/* Option: All Stores */} + + +
+ + {/* Filtered Stores */} + {filteredStores.length === 0 ? ( +
+ No stores match "{searchTerm}" +
+ ) : ( + filteredStores.map(store => { + const isSelected = selectedCustomer === store.customer; + return ( + + ); + }) + )} +
+
+ )} +
+
+ ); +}; + +export const FilterBar: React.FC = ({ + filters, + stores, + weekOptions, + onChange, + onReset +}) => { + // Extract unique options + const zones = Array.from(new Set(stores.map(s => s.zone))).filter(Boolean).sort(); + const channels = Array.from(new Set(stores.map(s => s.customerChannel))).filter(Boolean).sort(); + + const handleFieldChange = (field: keyof FilterState, value: string) => { + onChange({ + ...filters, + [field]: value + }); + }; + + const isFiltered = + filters.zone !== 'all' || + filters.channel !== 'all' || + filters.customer !== 'all' || + filters.status !== 'all' || + filters.oosStatus !== 'all' || + (filters.selectedWeek && filters.selectedWeek !== 'all') || + filters.search.trim() !== ''; + + return ( +
+
+
+
+ +
+

+ Audit Dashboard Filters +

+
+ {isFiltered && ( + + )} +
+ +
+ + {/* Searchable Store Select Dropdown */} +
+ handleFieldChange('customer', custName)} + /> +
+ + {/* Week / Period Filter */} +
+ + +
+ + {/* Freeform Search (Store Code) */} +
+ +
+ + handleFieldChange('search', e.target.value)} + placeholder="e.g. CUST-1001" + className="w-full pl-9 pr-3 py-1.5 text-xs bg-sky-50/40 border border-sky-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 focus:bg-white transition-all text-slate-900 placeholder-slate-400 font-medium" + /> +
+
+ + {/* Zone */} +
+ + +
+ + {/* Channel */} +
+ + +
+ + {/* Audit Status */} +
+ + +
+ +
+
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..dfaf593 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { LogOut, Presentation, RefreshCw, UserRound, Zap } from 'lucide-react'; + +interface HeaderProps { + lastSynced: string; + isSyncing: boolean; + onRefresh: () => void; + onOpenTutorial: () => void; + userEmail: string; + onLogout: () => void; +} + +export const Header: React.FC = ({ + lastSynced, + isSyncing, + onRefresh, + onOpenTutorial, + userEmail, + onLogout +}) => { + return ( +
+
+
+
+
+ +
+
+
+ + ICE KICK + +

+ Lucozade Sport Store Audit +

+
+

+ POS Audit Dashboard, OOS Availability & Inventory Tracker +

+
+
+ +
+ + +
+ + Last Sync + + + {lastSynced || 'Just Now'} + +
+ + + +
+ + {userEmail} +
+ + +
+
+
+
+ ); +}; diff --git a/src/components/KPICards.tsx b/src/components/KPICards.tsx new file mode 100644 index 0000000..df33999 --- /dev/null +++ b/src/components/KPICards.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { DashboardKPIs } from '../types'; +import { + Building2, + CheckCircle2, + AlertTriangle, + PackageCheck, + Tag, + Percent, + Layers, + Sparkles, + ShoppingBag, + DollarSign, +} from 'lucide-react'; + +interface KPICardsProps { + kpis: DashboardKPIs; +} + +export const KPICards: React.FC = ({ kpis }) => { + return ( +
+ {/* Primary KPI Cards Grid - Exactly 2 rows on large screens (5 columns per row) */} +
+ + {/* 1. Unified Store Coverage Card (Spans 2 columns on lg screens) */} +
+
+ + Store Audit Coverage + +
+ +
+
+ +
+
+
+ + {kpis.complianceRate.toFixed(1)}% + + Coverage +
+
+
+
+
+ +
+
+
Total
+
{kpis.totalStores}
+
+
+
+
Visited
+
{kpis.visitedStores}
+
+
+
+
+ + {/* 2. Out Of Stock (OOS) Rate */} +
+
+ + Out Of Stock (OOS) + +
0 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600' + }`}> + +
+
+
+
+ 0 ? 'text-rose-600' : 'text-emerald-600' + }`}> + {kpis.oosRate.toFixed(1)}% + + + ({kpis.oosStores}/{kpis.visitedStores}) + +
+

+ In Stock: {kpis.availableStores}/{kpis.visitedStores} ({kpis.numericDistribution.toFixed(1)}%) +

+
+
+ + {/* 5. % Additional Exhibition (Secondary Display) */} +
+
+ + % Add. Exhibition (Secondary) + +
+ +
+
+
+
+ + {kpis.secondaryDisplayRate.toFixed(1)}% + + + ({kpis.secondaryDisplayStores}/{kpis.visitedStores}) + +
+

+ Stores with Secondary Display +

+
+
+ + {/* 6. % Additional Exhibition (Gondola End) */} +
+
+ + % Add. Exhibition (Gondola) + +
+ +
+
+
+
+ + {kpis.gondolaEndRate.toFixed(1)}% + + + ({kpis.gondolaEndStores}/{kpis.visitedStores}) + +
+

+ Stores with Gondola End +

+
+
+ + {/* 7. Lucozade Brand Share */} +
+
+ + Lucozade Brand Share + +
+ +
+
+
+
+ {kpis.avgBrandShelfShare.toFixed(1)}% +
+

+ Ice Kick vs Lucozade Brand +

+
+
+ + {/* 8. Avg Shelf Price */} +
+
+ + Avg Shelf Price + +
+ +
+
+
+
+ ${kpis.avgRetailPrice.toFixed(2)} +
+

+ Avg. consumer retail price +

+
+
+ + {/* 9. Volume Sell-Out */} +
+
+ + Volume Sell-Out + +
+ +
+
+
+
+ {kpis.totalSellOutUnits.toLocaleString()} units +
+

+ Sell-In - Physical Inventory +

+
+
+ + {/* 10. Value Sell-Out */} +
+
+ + Value Sell-Out + +
+ +
+
+
+
+ ${kpis.totalSellOutValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+

+ Volume Sell-Out * Shelf Price +

+
+
+ + {/* 11. Physical Inventory */} +
+
+ + Physical Inventory + +
+ +
+
+
+
+ {kpis.totalPhysicalInventory.toLocaleString()} units +
+

+ Sales Floor + Backroom +

+
+
+ +
+
+ ); +}; + diff --git a/src/components/StoreAuditModal.tsx b/src/components/StoreAuditModal.tsx new file mode 100644 index 0000000..002ef43 --- /dev/null +++ b/src/components/StoreAuditModal.tsx @@ -0,0 +1,308 @@ +import React, { useState } from 'react'; +import { MergedStoreAudit } from '../types'; +import { + X, + MapPin, + CheckCircle2, + XCircle, + Image as ImageIcon, + Calendar +} from 'lucide-react'; + +interface StoreAuditModalProps { + item: MergedStoreAudit | null; + onClose: () => void; +} + +export const StoreAuditModal: React.FC = ({ + item, + onClose +}) => { + if (!item) return null; + + const { store, response, status, hasOOS } = item; + const isVisited = status === 'Visited'; + const [selectedPhoto, setSelectedPhoto] = useState(null); + + const allPhotos = [ + ...(response?.shelfPhotos || []).map(url => ({ url, title: 'Shelf / Display Photo' })), + ...(response?.popPhotos || []).map(url => ({ url, title: 'POP Material Photo' })), + ...(response?.categoryPhotos || []).map(url => ({ url, title: 'Category Gondola Photo' })) + ]; + + return ( +
+
+ + {/* Modal Header */} +
+
+
+ + Audit Report File + + {store.customerCode} +
+

{store.customer}

+

+ + {store.address} 鈥 {store.zone}{store.customerChannel} +

+
+ + +
+ + {/* Modal Content */} +
+ + {/* Metadata Row */} +
+
+ Assigned Auditor + {store.auditor || 'Unassigned'} +
+
+ Visit Days + {store.visitDays?.join(', ') || 'Monday - Friday'} +
+
+ Sell-In (Units Sold In) + {(store.unitsSoldIn || 0).toLocaleString()} units +
+
+ Latest Report Date + {response?.submissionDate || 'No report'} +
+
+ + {/* All Recorded Weekly Visits History */} + {item.allResponses && item.allResponses.length > 0 && ( +
+

+ + Weekly Visit History for this POS ({item.allResponses.length} total recorded visits) +

+
+ {item.allResponses.map((res, idx) => ( +
+
+ {res.submissionDate} + {res.placementLocation || 'Main Shelf'} 鈥 POP: {res.popVisible || 'None'} +
+
+ + {res.isAvailable === 'Yes' ? 'Available' : 'Out of Stock'} + + ${(res.retailPrice || 0).toFixed(2)} + {res.totalPhysicalInventory || 0} units +
+
+ ))} +
+
+ )} + + {!isVisited ? ( +
+

Store Pending Audit

+

No form response has been recorded for this store yet.

+
+ ) : ( +
+ + {/* STEP 1 */} +
+
+
+ + 1 + +

Step 1: Presence Validation (First Impression)

+
+ {response?.isAvailable === 'Yes' ? ( + + + Available for Sale Today (Yes) + + ) : ( + + + Out of Stock (No) + + )} +
+ + {hasOOS && ( +
+ + + Auditor Alert: Product not available on sales floor. Proceeded to Step 4 (Backroom) to inspect stored stock. + +
+ )} +
+ + {/* STEP 2 */} +
+
+ + 2 + +

Step 2: Placement & Display (Store Mapping)

+
+ +
+
+ Product Location + {response?.placementLocation || 'N/A'} +
+
+ Promotional Material (POP) + {response?.popVisible || 'No POP'} +
+
+
+ + {/* STEP 3 */} +
+
+ + 3 + +

Step 3: Main Shelf Measurement (Detailed Work)

+
+ +
+
+ Retail Price + ${(response?.retailPrice || 0).toFixed(2)} +
+
+ Ice Kick Facings + {response?.facingsIceKick || 0} facings +
+
+ Lucozade Brand Share + + {response?.facingsLucozadeBrand && response.facingsLucozadeBrand > 0 + ? (((response.facingsIceKick || 0) / response.facingsLucozadeBrand) * 100).toFixed(1) + : 0}% + + ({response?.facingsLucozadeBrand || 0} Lucozade facings) +
+
+ Total Category Share + + {response?.facingsCategoryTotal && response.facingsCategoryTotal > 0 + ? (((response.facingsLucozadeBrand || 0) / response.facingsCategoryTotal) * 100).toFixed(1) + : 0}% + + ({response?.facingsCategoryTotal || 0} total facings) +
+
+ +
+ Space Substitution (SKU Substitution): + {response?.skuSubstitution || 'No substitution recorded'} +
+
+ + {/* STEP 4 */} +
+
+
+ + 4 + +

Step 4: Inventory & Closure (Store Manager Interview)

+
+ + Value Sell-Out: ${((response?.totalPhysicalInventory || 0) * (response?.retailPrice || 0)).toFixed(2)} + +
+ +
+
+
+ Total Physical Inventory + Sales Floor + Backroom +
+ + {(response?.totalPhysicalInventory || 0).toLocaleString()} units + +
+
+
+ Total Sell-In + Dispatched / Sold-In units +
+ + {(store.unitsSoldIn || 0).toLocaleString()} units + +
+
+
+ + {/* PHOTOS SECTION */} + {allPhotos.length > 0 && ( +
+

+ + Attached Photographs ({allPhotos.length}) +

+
+ {allPhotos.map((photo, i) => ( +
setSelectedPhoto(photo.url)} + className="group relative rounded-xl overflow-hidden border border-sky-100 aspect-square bg-slate-100 cursor-pointer hover:border-sky-500 hover:shadow-xs transition-all" + > + {photo.title} +
+ {photo.title} +
+
+ ))} +
+
+ )} + +
+ )} + +
+ + {/* Modal Footer */} +
+ +
+ +
+ + {/* Lightbox */} + {selectedPhoto && ( +
setSelectedPhoto(null)} + className="fixed inset-0 z-60 bg-slate-950/80 backdrop-blur-md flex items-center justify-center p-4 cursor-pointer" + > +
+ Zoom +
+
+ )} + +
+ ); +}; + diff --git a/src/components/StoreTable.tsx b/src/components/StoreTable.tsx new file mode 100644 index 0000000..bb9956d --- /dev/null +++ b/src/components/StoreTable.tsx @@ -0,0 +1,379 @@ +import React, { useState } from 'react'; +import { MergedStoreAudit } from '../types'; +import { Store, CheckCircle2, XCircle, Clock, Eye, Download } from 'lucide-react'; + +interface StoreTableProps { + mergedData: MergedStoreAudit[]; + onSelectStore: (item: MergedStoreAudit) => void; +} + +export const StoreTable: React.FC = ({ + mergedData, + onSelectStore +}) => { + const [sortField, setSortField] = useState('customer'); + const [sortAsc, setSortAsc] = useState(true); + + const handleSort = (field: string) => { + if (sortField === field) { + setSortAsc(!sortAsc); + } else { + setSortField(field); + setSortAsc(true); + } + }; + + const sortedData = [...mergedData].sort((a, b) => { + let valA: any = a.store.customer; + let valB: any = b.store.customer; + + const resA = a.response; + const resB = b.response; + + const priceA = resA?.retailPrice || 0; + const priceB = resB?.retailPrice || 0; + const invA = resA?.totalPhysicalInventory || 0; + const invB = resB?.totalPhysicalInventory || 0; + const sellInA = a.store.unitsSoldIn || 0; + const sellInB = b.store.unitsSoldIn || 0; + const sellOutA = Math.max(0, sellInA - invA); + const sellOutB = Math.max(0, sellInB - invB); + + if (sortField === 'zone') { + valA = a.store.zone; + valB = b.store.zone; + } else if (sortField === 'channel') { + valA = a.store.customerChannel; + valB = b.store.customerChannel; + } else if (sortField === 'date') { + valA = resA?.submissionDate || a.store.assignedDate || ''; + valB = resB?.submissionDate || b.store.assignedDate || ''; + } else if (sortField === 'status') { + valA = a.status; + valB = b.status; + } else if (sortField === 'oos') { + valA = resA?.isAvailable || ''; + valB = resB?.isAvailable || ''; + } else if (sortField === 'placement') { + valA = resA?.placementLocation || ''; + valB = resB?.placementLocation || ''; + } else if (sortField === 'pop') { + valA = resA?.popVisible || ''; + valB = resB?.popVisible || ''; + } else if (sortField === 'price') { + valA = priceA; + valB = priceB; + } else if (sortField === 'iceKickFacings') { + valA = resA?.facingsIceKick || 0; + valB = resB?.facingsIceKick || 0; + } else if (sortField === 'lucozadeFacings') { + valA = resA?.facingsLucozadeBrand || 0; + valB = resB?.facingsLucozadeBrand || 0; + } else if (sortField === 'inventory') { + valA = invA; + valB = invB; + } else if (sortField === 'sellIn') { + valA = sellInA; + valB = sellInB; + } else if (sortField === 'sellOut') { + valA = sellOutA; + valB = sellOutB; + } else if (sortField === 'sellOutValue') { + valA = sellOutA * priceA; + valB = sellOutB * priceB; + } + + if (valA < valB) return sortAsc ? -1 : 1; + if (valA > valB) return sortAsc ? 1 : -1; + return 0; + }); + + const formatCsvCell = (val: string | number | undefined | null): string => { + if (val === null || val === undefined) return '""'; + // Clean up any embedded carriage returns or newlines to keep single-row per store in Excel + const cleanStr = String(val).replace(/[\r\n]+/g, ' ').trim(); + // Escape double quotes according to standard CSV spec (replace " with "") + const escaped = cleanStr.replace(/"/g, '""'); + return `"${escaped}"`; + }; + + const exportCSV = () => { + const headers = [ + 'Punto de Venta', + 'Fecha Visita', + 'Zona', + 'Channel', + 'Estatus de Auditor铆a', + 'OOS Availability', + 'Placement', + 'POP', + 'Price Shelf ($)', + 'Ice Kick Facings', + 'Lucozade Facings', + 'Physical Inventory', + 'Sell In', + 'Sell Out', + 'Sell Out Value ($)' + ]; + + const rows = sortedData.map(m => { + const isVisited = m.status === 'Visited'; + const res = m.response; + const price = res?.retailPrice || 0; + const inv = res?.totalPhysicalInventory || 0; + const sellIn = m.store.unitsSoldIn || 0; + const sellOut = isVisited ? Math.max(0, sellIn - inv) : 0; + const sellOutVal = sellOut * price; + + return [ + m.store.customer, + res?.submissionDate || m.store.assignedDate || 'Pending', + m.store.zone, + m.store.customerChannel, + isVisited ? 'Visited' : 'Pending', + isVisited ? (res?.isAvailable === 'Yes' ? 'Available' : 'Out of Stock') : 'Not Reported', + isVisited ? (res?.placementLocation || 'N/A') : 'N/A', + isVisited ? (res?.popVisible || 'No POP') : 'N/A', + isVisited ? price.toFixed(2) : '0.00', + isVisited ? (res?.facingsIceKick || 0) : 0, + isVisited ? (res?.facingsLucozadeBrand || 0) : 0, + isVisited ? inv : 0, + sellIn, + isVisited ? sellOut : 0, + isVisited ? sellOutVal.toFixed(2) : '0.00' + ]; + }); + + const csvLines = [ + headers.map(h => formatCsvCell(h)).join(','), + ...rows.map(r => r.map(cell => formatCsvCell(cell)).join(',')) + ].join('\r\n'); + + // Include UTF-8 BOM (\uFEFF) so Excel, Google Sheets, and Numbers auto-detect encoding and column delimiters + const blob = new Blob(['\uFEFF' + csvLines], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `lucozade_audit_master_table_${new Date().toISOString().slice(0, 10)}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + return ( +
+ + {/* Table Header Controls */} +
+
+

+
+ +
+ Consolidated Store Audit Directory +

+

+ Complete store audit breakdown across 14 independent key performance indicators. +

+
+ + +
+ + {/* Table Body - Fira Sans font for directory data */} +
+ + + + + + + + + + + + + + + + + + + + + + + {sortedData.map(item => { + const res = item.response; + const isVisited = item.status === 'Visited'; + const price = res?.retailPrice || 0; + const inv = res?.totalPhysicalInventory || 0; + const sellIn = item.store.unitsSoldIn || 0; + const sellOut = isVisited ? Math.max(0, sellIn - inv) : 0; + const sellOutVal = sellOut * price; + + return ( + + + {/* 1. Punto de Venta */} + + + {/* Visit Date / Week */} + + + {/* 2. Zona */} + + + {/* 3. Channel */} + + + {/* 4. Estatus de Auditor铆a */} + + + {/* 5. OOS Availability */} + + + {/* 6. Placement */} + + + {/* 7. POP */} + + + {/* 8. Price Shelf */} + + + {/* 9. Ice Kick Facings */} + + + {/* 10. Lucozade Facings */} + + + {/* 11. Physical Inventory */} + + + {/* 12. Sell In */} + + + {/* 13. Sell Out */} + + + {/* 14. Sell Out Value */} + + + {/* Action */} + + + + ); + })} + +
handleSort('customer')} className="px-3.5 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Point of Sale (Store) 鈫戔啌 + handleSort('date')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Visit Date / Week 鈫戔啌 + handleSort('zone')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Zone 鈫戔啌 + handleSort('channel')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Channel 鈫戔啌 + handleSort('status')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Audit Status 鈫戔啌 + handleSort('oos')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + OOS Availability 鈫戔啌 + handleSort('placement')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Placement 鈫戔啌 + handleSort('pop')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + POP 鈫戔啌 + handleSort('price')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Price Shelf 鈫戔啌 + handleSort('iceKickFacings')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Ice Kick Facings 鈫戔啌 + handleSort('lucozadeFacings')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Lucozade Facings 鈫戔啌 + handleSort('inventory')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Physical Inventory 鈫戔啌 + handleSort('sellIn')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors"> + Sell In 鈫戔啌 + handleSort('sellOut')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors bg-amber-50/50 text-amber-800"> + Sell Out 鈫戔啌 + handleSort('sellOutValue')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors bg-emerald-50/50 text-emerald-800"> + Sell Out Value 鈫戔啌 + Action
+
{item.store.customer}
+ + {item.store.address} + +
+ {res?.submissionDate || item.store.assignedDate || 'Pending'} + + {item.store.zone} + + {item.store.customerChannel} + + {isVisited ? ( + + + Visited + + ) : ( + + + Pending + + )} + + {isVisited ? ( + res?.isAvailable === 'Yes' ? ( + + + Available + + ) : ( + + + Out of Stock + + ) + ) : ( + Not Reported + )} + + {isVisited ? (res?.placementLocation || 'N/A') : '-'} + + {isVisited ? (res?.popVisible || 'No POP') : '-'} + + {isVisited ? `$${price.toFixed(2)}` : '-'} + + {isVisited ? res?.facingsIceKick || 0 : '-'} + + {isVisited ? res?.facingsLucozadeBrand || 0 : '-'} + + {isVisited ? `${inv.toLocaleString()} units` : '-'} + + {sellIn.toLocaleString()} units + + {isVisited ? `${sellOut.toLocaleString()} units` : '-'} + + {isVisited ? `$${sellOutVal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '-'} + + +
+
+
+ ); +}; + + diff --git a/src/components/SyncSheetModal.tsx b/src/components/SyncSheetModal.tsx new file mode 100644 index 0000000..239fc5d --- /dev/null +++ b/src/components/SyncSheetModal.tsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { X, Database, ExternalLink } from 'lucide-react'; +import { DEFAULT_SPREADSHEET_ID } from '../services/googleSheets'; + +interface SyncSheetModalProps { + isOpen: boolean; + currentSpreadsheetId: string; + source: 'google_sheets_api' | 'google_sheets_csv' | 'fallback_demo'; + error?: string; + onClose: () => void; + onUpdateSpreadsheetId: (newId: string) => void; +} + +export const SyncSheetModal: React.FC = ({ + isOpen, + currentSpreadsheetId, + source, + error, + onClose, + onUpdateSpreadsheetId +}) => { + if (!isOpen) return null; + + const [inputVal, setInputVal] = useState(currentSpreadsheetId); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (inputVal.trim()) { + onUpdateSpreadsheetId(inputVal.trim()); + onClose(); + } + }; + + const sheetUrl = currentSpreadsheetId.startsWith('http') + ? currentSpreadsheetId + : `https://docs.google.com/spreadsheets/d/${currentSpreadsheetId}/edit`; + + return ( +
+
+ + {/* Header */} +
+
+
+ +
+

Google Sheets Configuration

+
+ +
+ + {/* Content */} +
+ +
+

Connected Google Sheets Database:

+

+ {currentSpreadsheetId} +

+ + Open Google Sheet in new tab + +
+ +
+
+ + setInputVal(e.target.value)} + placeholder="https://docs.google.com/spreadsheets/d/e/2PACX-.../pubhtml" + className="w-full px-3.5 py-2.5 text-xs bg-sky-50/50 border border-sky-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-400 font-mono text-slate-900 placeholder-slate-400 font-bold" + /> +
+ +
+ Required Tab Structure: +
    +
  • Form responses: Audit form responses tab
  • +
  • tiendas: Master stores list tab
  • +
+
+ +
+ + +
+ + +
+
+ +
+ +
+ +
+
+ ); +}; + diff --git a/src/data/mockData.ts b/src/data/mockData.ts new file mode 100644 index 0000000..bd2f5e1 --- /dev/null +++ b/src/data/mockData.ts @@ -0,0 +1,419 @@ +import { Store, AuditFormResponse } from '../types'; + +export const INITIAL_STORES: Store[] = [ + // WEEK 1 ASSIGNMENTS (2026-07-20 to 2026-07-26) + { + customer: "Supermercado El Rey - Calle 50", + zone: "Zona Centro", + address: "Calle 50 y San Francisco, N掳 102", + coordinates: "8.9833, -79.5167", + customerCode: "CUST-1001", + customerChannel: "Supermercados", + csSoldIn: 120, + unitsSoldIn: 1440, + lSoldIn: 720, + auditor: "Auditor #01 - Carlos Ruiz", + visitDays: ["Monday", "Thursday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Super 99 - V铆a Porras", + zone: "Zona Centro", + address: "V铆a Porras y Calle 68, San Francisco", + coordinates: "8.9891, -79.5102", + customerCode: "CUST-1002", + customerChannel: "Supermercados", + csSoldIn: 95, + unitsSoldIn: 1140, + lSoldIn: 570, + auditor: "Auditor #01 - Carlos Ruiz", + visitDays: ["Monday", "Wednesday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Riba Smith - Bella Vista", + zone: "Zona Centro", + address: "Av. Justo Arosemena, Bella Vista", + coordinates: "8.9744, -79.5298", + customerCode: "CUST-1003", + customerChannel: "Supermercados", + csSoldIn: 150, + unitsSoldIn: 1800, + lSoldIn: 900, + auditor: "Auditor #02 - Ana G贸mez", + visitDays: ["Tuesday", "Friiday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Minisuper La Bendici贸n - San Miguelito", + zone: "Zona Norte", + address: "Calle Principal, Sector 3, San Miguelito", + coordinates: "9.0333, -79.5000", + customerCode: "CUST-1004", + customerChannel: "Tradicional", + csSoldIn: 30, + unitsSoldIn: 360, + lSoldIn: 180, + auditor: "Auditor #03 - Jorge Mendoza", + visitDays: ["Monday", "Wednesday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Tienda Va y Ven - Terpel Brisas", + zone: "Zona Este", + address: "Av. Manuel E. Batista, Brisas del Golf", + coordinates: "9.0512, -79.4520", + customerCode: "CUST-1005", + customerChannel: "Conveniencia", + csSoldIn: 45, + unitsSoldIn: 540, + lSoldIn: 270, + auditor: "Auditor #02 - Ana G贸mez", + visitDays: ["Tuesday", "Thursday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Farmacias Arrocha - Costa del Este", + zone: "Zona Este", + address: "Paseo del Mar, Costa del Este", + coordinates: "9.0110, -79.4700", + customerCode: "CUST-1006", + customerChannel: "Farmacias", + csSoldIn: 60, + unitsSoldIn: 720, + lSoldIn: 360, + auditor: "Auditor #04 - Luisa Fern谩ndez", + visitDays: ["Wednesday", "Friiday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Pricesmart - V铆a Espa帽a", + zone: "Zona Centro", + address: "V铆a Espa帽a y Calle 12, Carrasquilla", + coordinates: "8.9950, -79.5080", + customerCode: "CUST-1007", + customerChannel: "Clubes de Compra", + csSoldIn: 300, + unitsSoldIn: 3600, + lSoldIn: 1800, + auditor: "Auditor #01 - Carlos Ruiz", + visitDays: ["Monday", "Friiday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Super Carnes - David Chiriqu铆", + zone: "Zona Oeste", + address: "Av. Central, David, Chiriqu铆", + coordinates: "8.4273, -82.4308", + customerCode: "CUST-1008", + customerChannel: "Supermercados", + csSoldIn: 110, + unitsSoldIn: 1320, + lSoldIn: 660, + auditor: "Auditor #05 - Roberto Blanco", + visitDays: ["Tuesday", "Thursday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Minisuper Express - Chorrera", + zone: "Zona Oeste", + address: "Av. las Am茅ricas, La Chorrera", + coordinates: "8.8803, -79.7833", + customerCode: "CUST-1009", + customerChannel: "Tradicional", + csSoldIn: 25, + unitsSoldIn: 300, + lSoldIn: 150, + auditor: "Auditor #05 - Roberto Blanco", + visitDays: ["Wednesday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + { + customer: "Deli Gourmet - Obarrio", + zone: "Zona Centro", + address: "Calle 54, Obarrio", + coordinates: "8.9865, -79.5190", + customerCode: "CUST-1010", + customerChannel: "Conveniencia", + csSoldIn: 40, + unitsSoldIn: 480, + lSoldIn: 240, + auditor: "Auditor #02 - Ana G贸mez", + visitDays: ["Monday", "Thursday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-20" + }, + + // WEEK 2 ASSIGNMENTS (2026-07-27 to 2026-08-02) + { + customer: "Supermercado El Rey - Calle 50", + zone: "Zona Centro", + address: "Calle 50 y San Francisco, N掳 102", + coordinates: "8.9833, -79.5167", + customerCode: "CUST-1001", + customerChannel: "Supermercados", + csSoldIn: 120, + unitsSoldIn: 1440, + lSoldIn: 720, + auditor: "Auditor #01 - Carlos Ruiz", + visitDays: ["Monday", "Thursday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-27" + }, + { + customer: "Super 99 - V铆a Porras", + zone: "Zona Centro", + address: "V铆a Porras y Calle 68, San Francisco", + coordinates: "8.9891, -79.5102", + customerCode: "CUST-1002", + customerChannel: "Supermercados", + csSoldIn: 95, + unitsSoldIn: 1140, + lSoldIn: 570, + auditor: "Auditor #01 - Carlos Ruiz", + visitDays: ["Monday", "Wednesday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-27" + }, + { + customer: "Riba Smith - Bella Vista", + zone: "Zona Centro", + address: "Av. Justo Arosemena, Bella Vista", + coordinates: "8.9744, -79.5298", + customerCode: "CUST-1003", + customerChannel: "Supermercados", + csSoldIn: 150, + unitsSoldIn: 1800, + lSoldIn: 900, + auditor: "Auditor #02 - Ana G贸mez", + visitDays: ["Tuesday", "Friiday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-27" + }, + { + customer: "Minisuper La Bendici贸n - San Miguelito", + zone: "Zona Norte", + address: "Calle Principal, Sector 3, San Miguelito", + coordinates: "9.0333, -79.5000", + customerCode: "CUST-1004", + customerChannel: "Tradicional", + csSoldIn: 30, + unitsSoldIn: 360, + lSoldIn: 180, + auditor: "Auditor #03 - Jorge Mendoza", + visitDays: ["Monday", "Wednesday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-27" + }, + { + customer: "Farmacias Arrocha - Costa del Este", + zone: "Zona Este", + address: "Paseo del Mar, Costa del Este", + coordinates: "9.0110, -79.4700", + customerCode: "CUST-1006", + customerChannel: "Farmacias", + csSoldIn: 60, + unitsSoldIn: 720, + lSoldIn: 360, + auditor: "Auditor #04 - Luisa Fern谩ndez", + visitDays: ["Wednesday", "Friiday"], + skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML", + assignedDate: "2026-07-27" + } +]; + +export const INITIAL_RESPONSES: AuditFormResponse[] = [ + // WEEK 1 AUDITS (Jul 20 - Jul 26, 2026) + { + submissionId: "SUB-88201", + submissionDate: "2026-07-20 09:15", + customer: "Supermercado El Rey - Calle 50", + isAvailable: "Yes", + placementLocation: "Estante Principal", + shelfPhotos: [ + "https://images.unsplash.com/photo-1578916171728-46686eac8d58?auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80" + ], + popVisible: "Wobblers, Posters, Cenefa en buen estado", + popPhotos: [ + "https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?auto=format&fit=crop&w=800&q=80" + ], + retailPrice: 2.25, + facingsIceKick: 6, + facingsLucozadeBrand: 14, + facingsCategoryTotal: 48, + categoryPhotos: [ + "https://images.unsplash.com/photo-1622483767028-3f66f32aef97?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "Gatorade Cool Blue 500ml (-2 frentes)", + totalPhysicalInventory: 340, + lastUpdateDate: "2026-07-20 09:30" + }, + { + submissionId: "SUB-88202", + submissionDate: "2026-07-20 11:20", + customer: "Super 99 - V铆a Porras", + isAvailable: "Yes", + placementLocation: "Exhibici贸n Secundaria", + shelfPhotos: [ + "https://images.unsplash.com/photo-1583258292688-d0213dc5a3a8?auto=format&fit=crop&w=800&q=80" + ], + popVisible: "Poster Promocional y Stopper", + popPhotos: [ + "https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?auto=format&fit=crop&w=800&q=80" + ], + retailPrice: 2.30, + facingsIceKick: 4, + facingsLucozadeBrand: 10, + facingsCategoryTotal: 40, + categoryPhotos: [ + "https://images.unsplash.com/photo-1534723452862-4c874018d66d?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "Powerade Mountain Blast 500ml (-2 frentes)", + totalPhysicalInventory: 185, + lastUpdateDate: "2026-07-20 11:45" + }, + { + submissionId: "SUB-88203", + submissionDate: "2026-07-21 08:45", + customer: "Riba Smith - Bella Vista", + isAvailable: "Yes", + placementLocation: "Cabecera de G贸ndola", + shelfPhotos: [ + "https://images.unsplash.com/photo-1604719312566-8912e9227c6a?auto=format&fit=crop&w=800&q=80" + ], + popVisible: "Wobbler y Cabecera Decorada Completa", + popPhotos: [ + "https://images.unsplash.com/photo-1507679799987-c73779587ccf?auto=format&fit=crop&w=800&q=80" + ], + retailPrice: 2.50, + facingsIceKick: 8, + facingsLucozadeBrand: 18, + facingsCategoryTotal: 52, + categoryPhotos: [ + "https://images.unsplash.com/photo-1578916171728-46686eac8d58?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "Red Bull 250ml Regular (-3 frentes)", + totalPhysicalInventory: 420, + lastUpdateDate: "2026-07-21 09:05" + }, + { + submissionId: "SUB-88204", + submissionDate: "2026-07-21 10:10", + customer: "Minisuper La Bendici贸n - San Miguelito", + isAvailable: "No", // OUT OF STOCK! + placementLocation: "N/A - Agotado", + shelfPhotos: [], + popVisible: "Sin material POP visible", + popPhotos: [], + retailPrice: 0, + facingsIceKick: 0, + facingsLucozadeBrand: 2, + facingsCategoryTotal: 18, + categoryPhotos: [ + "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "N/A - Producto sin stock", + totalPhysicalInventory: 0, + lastUpdateDate: "2026-07-21 10:20" + }, + { + submissionId: "SUB-88205", + submissionDate: "2026-07-21 14:00", + customer: "Tienda Va y Ven - Terpel Brisas", + isAvailable: "Yes", + placementLocation: "Estante Principal", + shelfPhotos: [ + "https://images.unsplash.com/photo-1583258292688-d0213dc5a3a8?auto=format&fit=crop&w=800&q=80" + ], + popVisible: "Stopper de Nevera y Sticker de Precio", + popPhotos: [ + "https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?auto=format&fit=crop&w=800&q=80" + ], + retailPrice: 2.60, + facingsIceKick: 3, + facingsLucozadeBrand: 6, + facingsCategoryTotal: 24, + categoryPhotos: [ + "https://images.unsplash.com/photo-1534723452862-4c874018d66d?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "Monster Energy Original 473ml (-1 frente)", + totalPhysicalInventory: 64, + lastUpdateDate: "2026-07-21 14:15" + }, + + // WEEK 2 AUDITS (Jul 27 - Aug 02, 2026) + { + submissionId: "SUB-88211", + submissionDate: "2026-07-27 10:00", + customer: "Supermercado El Rey - Calle 50", + isAvailable: "Yes", + placementLocation: "Estante Principal", + shelfPhotos: [ + "https://images.unsplash.com/photo-1578916171728-46686eac8d58?auto=format&fit=crop&w=800&q=80" + ], + popVisible: "Wobblers y Cenefa", + popPhotos: [], + retailPrice: 2.25, + facingsIceKick: 8, + facingsLucozadeBrand: 16, + facingsCategoryTotal: 50, + categoryPhotos: [ + "https://images.unsplash.com/photo-1622483767028-3f66f32aef97?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "Gatorade Cool Blue (-2 frentes)", + totalPhysicalInventory: 310, + lastUpdateDate: "2026-07-27 10:20" + }, + { + submissionId: "SUB-88212", + submissionDate: "2026-07-28 11:30", + customer: "Super 99 - V铆a Porras", + isAvailable: "No", // OOS IN WEEK 2 + placementLocation: "N/A - Agotado", + shelfPhotos: [], + popVisible: "Poster Promocional", + popPhotos: [], + retailPrice: 0, + facingsIceKick: 0, + facingsLucozadeBrand: 8, + facingsCategoryTotal: 42, + categoryPhotos: [ + "https://images.unsplash.com/photo-1534723452862-4c874018d66d?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "N/A - Sin stock", + totalPhysicalInventory: 0, + lastUpdateDate: "2026-07-28 11:45" + }, + { + submissionId: "SUB-88213", + submissionDate: "2026-07-28 15:10", + customer: "Minisuper La Bendici贸n - San Miguelito", + isAvailable: "Yes", // RESTOCKED IN WEEK 2! + placementLocation: "Estante Principal", + shelfPhotos: [ + "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80" + ], + popVisible: "Poster reubicado", + popPhotos: [], + retailPrice: 2.20, + facingsIceKick: 3, + facingsLucozadeBrand: 6, + facingsCategoryTotal: 20, + categoryPhotos: [ + "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80" + ], + skuSubstitution: "Gatorade (-1 frente)", + totalPhysicalInventory: 85, + lastUpdateDate: "2026-07-28 15:25" + } +]; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..873b983 --- /dev/null +++ b/src/index.css @@ -0,0 +1,40 @@ +@import "tailwindcss"; +@import url('https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&family=Fira+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700&family=Geist:wght@100..900&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap'); + +@theme { + --font-geist: 'Geist', sans-serif; + --font-ibm: 'IBM Plex Sans', sans-serif; + --font-figtree: 'Figtree', sans-serif; + --font-fira: 'Fira Sans', sans-serif; +} + +html { + background: #f8fafc; +} + +body { + margin: 0; + min-width: 320px; + font-family: 'Figtree', sans-serif; + background: #f8fafc; +} + +button, input { + font: inherit; +} + +.font-geist { + font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.font-ibm { + font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.font-figtree { + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.font-fira { + font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..080dac3 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/services/googleSheets.ts b/src/services/googleSheets.ts new file mode 100644 index 0000000..ad9a253 --- /dev/null +++ b/src/services/googleSheets.ts @@ -0,0 +1,586 @@ +import Papa from 'papaparse'; +import { Store, AuditFormResponse, MergedStoreAudit } from '../types'; +import { INITIAL_STORES, INITIAL_RESPONSES } from '../data/mockData'; +import { parseDateString } from '../utils/dateUtils'; + +export const DEFAULT_SPREADSHEET_ID = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQL9z6VLMOImLy33neZsu4iT6gLJe9pUv3hukhF5uq_RQ71musz6y4k4ljV4ywYcUZLu25NcnaDw_Mw/pubhtml"; + +export interface FetchResult { + stores: Store[]; + responses: AuditFormResponse[]; + merged: MergedStoreAudit[]; + lastSynced: string; + source: 'google_sheets_api' | 'google_sheets_csv' | 'fallback_demo'; + error?: string; +} + +// Utility for header column value search with exact match priority +function getColValue(row: Record, keywords: string[]): string { + if (!row) return ''; + const keys = Object.keys(row); + + // 1. Exact match pass (case-insensitive, trimmed) + for (const keyword of keywords) { + const kw = keyword.toLowerCase().trim(); + const exactKey = keys.find(k => k.toLowerCase().trim() === kw); + if (exactKey && row[exactKey] !== undefined && row[exactKey] !== null) { + const val = String(row[exactKey]).trim(); + if (val !== '') return val; + } + } + + // 2. Partial match pass (includes) + for (const keyword of keywords) { + const kw = keyword.toLowerCase().trim(); + const matchedKey = keys.find(k => k.toLowerCase().trim().includes(kw)); + if (matchedKey && row[matchedKey] !== undefined && row[matchedKey] !== null) { + const val = String(row[matchedKey]).trim(); + if (val !== '') return val; + } + } + + return ''; +} + +function parseNumber(val: string): number { + if (!val) return 0; + const cleaned = val.replace(/[^0-9.-]+/g, ''); + const num = parseFloat(cleaned); + return isNaN(num) ? 0 : num; +} + +function normalizeName(str?: string): string { + if (!str || typeof str !== 'string') return ''; + return str + .toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +// Parse 'tiendas' sheet rows +export function parseStoresSheet(rows: any[]): Store[] { + if (!rows || rows.length === 0) return []; + + return rows.map((row, idx) => { + const customer = getColValue(row, ['Customer', 'Punto de venta', 'Tienda', 'Store', 'Nombre', 'Punto de Venta', 'Cliente', 'cliente']) || `Store #${idx + 1}`; + const zone = getColValue(row, ['Zone', 'Zona']) || 'General Zone'; + const address = getColValue(row, ['Address', 'Direccion', 'Direcci贸n']) || 'N/A'; + const coordinates = getColValue(row, ['Coordinates', 'Coordenadas']) || ''; + const customerCode = getColValue(row, ['Customer Code', 'Codigo cliente', 'C贸digo', 'Code', 'Codigo']) || `CUST-${1000 + idx}`; + const customerChannel = getColValue(row, ['Customer Channel', 'Canal', 'Channel']) || 'General'; + const csSoldIn = parseNumber(getColValue(row, ['Cs Sold In', 'Cajas'])); + const unitsSoldIn = parseNumber(getColValue(row, ['Units Sold In', 'Unidades'])); + const lSoldIn = parseNumber(getColValue(row, ['L Sold In', 'Litros'])); + const auditor = getColValue(row, ['Auditor #', 'Auditor']) || 'Assigned Auditor'; + + const visitDays: string[] = []; + if (getColValue(row, ['Monday', 'Lunes']).toLowerCase().includes('y') || getColValue(row, ['Monday']).length > 0) visitDays.push('Monday'); + if (getColValue(row, ['Tuesday', 'Martes']).toLowerCase().includes('y') || getColValue(row, ['Tuesday']).length > 0) visitDays.push('Tuesday'); + if (getColValue(row, ['Wednesday', 'Miercoles']).toLowerCase().includes('y') || getColValue(row, ['Wednesday']).length > 0) visitDays.push('Wednesday'); + if (getColValue(row, ['Thursday', 'Jueves']).toLowerCase().includes('y') || getColValue(row, ['Thursday']).length > 0) visitDays.push('Thursday'); + if (getColValue(row, ['Friiday', 'Friday', 'Viernes']).toLowerCase().includes('y') || getColValue(row, ['Friiday', 'Friday']).length > 0) visitDays.push('Friiday'); + + const skuDetail = getColValue(row, ['LUCOZADE SPORT', 'SKU', 'Lemon Lime']); + const assignedDate = getColValue(row, [ + 'Fecha Programada', + 'Fecha de Visita', + 'Fecha Visita', + 'Fecha de Asignaci贸n', + 'Fecha de asignacion', + 'Fecha Asignada', + 'Fecha Inicio', + 'Assigned Date', + 'Fecha', + 'Date', + 'fecha' + ]); + const week = getColValue(row, ['Week', 'week', 'SEMANA', 'Semana', 'Semanas', 'semana', 'Semana de auditor铆a', 'Semana Auditor铆a', 'Semana de Auditoria', 'semana_de_auditoria']); + + return { + customer, + zone, + address, + coordinates, + customerCode, + customerChannel, + csSoldIn, + unitsSoldIn, + lSoldIn, + auditor, + visitDays, + skuDetail, + assignedDate, + week + }; + }).filter(s => s.customer && s.customer !== 'N/A'); +} + +// Parse 'Form responses' sheet rows +export function parseResponsesSheet(rows: any[]): AuditFormResponse[] { + if (!rows || rows.length === 0) return []; + + return rows.map((row, idx) => { + const submissionId = getColValue(row, ['Submission ID', 'ID', 'Id', 'Timestamp', 'Marca temporal']) || `SUB-${Date.now()}-${idx}`; + const submissionDate = getColValue(row, [ + 'Marca temporal', + 'Timestamp', + 'Submission Date', + 'Fecha de Respuesta', + 'Fecha de Formulario', + 'Fecha de env铆o', + 'Fecha de envio', + 'Fecha de auditor铆a', + 'Fecha de auditoria', + 'Fecha', + 'Date', + 'fecha' + ]) || new Date().toISOString().slice(0, 16).replace('T', ' '); + const customer = getColValue(row, ['Dynamic Dropdowns', 'Punto de venta', 'Customer', 'Tienda', 'Store', 'Nombre', 'Punto de Venta', 'Cliente', 'cliente']) || ''; + + // Availability parsing with fuzzy keywords + const availVal = getColValue(row, [ + 'available', + 'available for sale', + 'Is Lucozade Sport Ice Kick available', + 'Ice Kick available', + 'Agotado', + 'Disponible', + 'Producto disponible', + 'isAvailable' + ]); + const availLower = (availVal || '').toLowerCase(); + + let isAvailable: 'Yes' | 'No' = 'No'; + if (availLower.includes('yes') || availLower.includes('si') || availLower.includes('s铆') || availLower === '1' || availLower === 'true' || availLower.includes('disponible')) { + isAvailable = 'Yes'; + } else if (availLower.includes('no') || availLower === '0' || availLower === 'false' || availLower.includes('agotado')) { + isAvailable = 'No'; + } else { + // Check if any value in the row equals yes/si + const rowStr = JSON.stringify(row).toLowerCase(); + if (rowStr.includes('"yes"') || rowStr.includes('disponible') || rowStr.includes('"si"')) { + isAvailable = 'Yes'; + } + } + + const placementLocation = getColValue(row, ['Where is the product located', 'Ubicacion', 'Ubicaci贸n', 'Location', 'Placement', 'Lugar']) || 'Main Shelf'; + + // Helper to parse multiple URLs separated by newlines, spaces, commas, etc. + const parsePhotoUrls = (raw: string): string[] => { + if (!raw) return []; + // Split on newlines, whitespace, commas, semicolons + const parts = raw.split(/[\r\n,;\s]+/); + return parts.map(s => s.trim()).filter(s => s.startsWith('http://') || s.startsWith('https://')); + }; + + // Photo URLs + const shelfPhotosRaw = getColValue(row, ['photos of the shelf', 'Add photos of the shelf', 'Fotos de estante', 'Shelf photos']); + const shelfPhotos = parsePhotoUrls(shelfPhotosRaw); + + const popVisible = getColValue(row, ['Is promotional (POP) material visible', 'POP material', 'Material POP', 'POP visible']) || 'Not Reported'; + + const popPhotosRaw = getColValue(row, ['photos of the material POP', 'Add photos of the material POP', 'Fotos POP']); + const popPhotos = parsePhotoUrls(popPhotosRaw); + + const retailPrice = parseNumber(getColValue(row, ['exact retail price', 'Consumer Price', 'Precio', 'Price', 'Retail Price'])); + const facingsIceKick = parseNumber(getColValue(row, ['facings of Lucozade Ice Kick', 'Facings (Ice Kick)', 'Facings Ice Kick', 'Ice Kick Facings'])); + const facingsLucozadeBrand = parseNumber(getColValue(row, ['facings of the entire Lucozade brand', 'Share of Brand Shelf', 'Facings Lucozade', 'Brand Facings'])); + const facingsCategoryTotal = parseNumber(getColValue(row, ['facings of the entire energy and sports drink category', 'Share of Shelf by Facings', 'Total Category Facings', 'Category Facings'])); + + const categoryPhotosRaw = getColValue(row, ['photos of the Energy & Sports Drinks shelf', 'Add photos of the Energy', 'Category Photos']); + const categoryPhotos = parsePhotoUrls(categoryPhotosRaw); + + const skuSubstitution = getColValue(row, ['Which brand/SKU gave up shelf space', 'SKU Substitution', 'Sustituci贸n', 'Sustitucion']) || 'None / N/A'; + const totalPhysicalInventory = parseNumber(getColValue(row, ['Total Physical Inventory', 'Inventario Fisico', 'Inventario F铆sico', 'Physical Inventory', 'Inventory'])); + const lastUpdateDate = getColValue(row, ['Last Update Date', 'Ultima actualizacion', '脷ltima actualizaci贸n']) || submissionDate; + const week = getColValue(row, ['Week', 'week', 'SEMANA', 'Semana', 'Semanas', 'semana', 'Semana de auditor铆a', 'Semana Auditor铆a', 'Semana de Auditoria', 'semana_de_auditoria']); + + return { + submissionId, + submissionDate, + customer, + isAvailable, + placementLocation, + shelfPhotos, + popVisible, + popPhotos, + retailPrice, + facingsIceKick, + facingsLucozadeBrand, + facingsCategoryTotal, + categoryPhotos, + skuSubstitution, + totalPhysicalInventory, + lastUpdateDate, + week + }; + }).filter(r => r.customer); +} + +function parseSubmissionTime(dateStr?: string): number { + if (!dateStr) return 0; + const d = parseDateString(dateStr); + return d ? d.getTime() : 0; +} + +function cleanStoreName(str?: string): string { + if (!str || typeof str !== 'string') return ''; + return str + .toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace(/\b(supermercado|minisuper|tienda|farmacia|arrocha|super|express|autoservicio|despensa|abarrotes|sucursal|suc|no|n掳|num|numero|vial)\b/gi, '') + .replace(/[^a-z0-9]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function calculateTokenOverlap(str1: string, str2: string): number { + const norm1 = normalizeName(str1); + const norm2 = normalizeName(str2); + if (!norm1 || !norm2) return 0; + if (norm1 === norm2) return 1.0; + + const tokens1 = new Set(norm1.split(' ').filter(t => t.length > 1)); + const tokens2 = new Set(norm2.split(' ').filter(t => t.length > 1)); + if (tokens1.size === 0 || tokens2.size === 0) return 0; + + let intersection = 0; + tokens1.forEach(t => { + if (tokens2.has(t)) intersection++; + }); + + const union = new Set([...tokens1, ...tokens2]).size; + return union > 0 ? intersection / union : 0; +} + +// Combine Stores and Responses by matching exact Customer name or Customer Code, plus fuzzy fallback +export function mergeAuditData(stores: Store[], responses: AuditFormResponse[]): MergedStoreAudit[] { + if (!stores || stores.length === 0) return []; + + const storeResponsesMap = new Map(); + const assignedResponseIds = new Set(); + + const attach = (storeIdx: number, resp: AuditFormResponse) => { + if (!storeResponsesMap.has(storeIdx)) { + storeResponsesMap.set(storeIdx, []); + } + storeResponsesMap.get(storeIdx)!.push(resp); + assignedResponseIds.add(resp.submissionId); + }; + + const storeMeta = stores.map((s, idx) => ({ + idx, + store: s, + normName: normalizeName(s.customer), + codeNorm: normalizeName(s.customerCode), + cleanName: cleanStoreName(s.customer) + })); + + // PASS 1: Exact match on Customer Name or Customer Code + responses.forEach(resp => { + if (assignedResponseIds.has(resp.submissionId)) return; + const respNorm = normalizeName(resp.customer); + const respCode = normalizeName((resp as any).customerCode); + + const match = storeMeta.find(m => + (respNorm && m.normName === respNorm) || + (respCode && m.codeNorm && respCode === m.codeNorm) + ); + + if (match) { + attach(match.idx, resp); + } + }); + + // PASS 2: Cleaned Name match or Substring/Inclusion Match + responses.forEach(resp => { + if (assignedResponseIds.has(resp.submissionId)) return; + const respNorm = normalizeName(resp.customer); + const respClean = cleanStoreName(resp.customer); + + const match = storeMeta.find(m => { + if (respClean && m.cleanName && respClean === m.cleanName) return true; + if (respNorm.length >= 4 && m.normName.length >= 4) { + if (respNorm.includes(m.normName) || m.normName.includes(respNorm)) return true; + } + return false; + }); + + if (match) { + attach(match.idx, resp); + } + }); + + // PASS 3: Token Overlap Similarity (>= 35%) + responses.forEach(resp => { + if (assignedResponseIds.has(resp.submissionId)) return; + const respNorm = normalizeName(resp.customer); + if (!respNorm) return; + + let bestMatchIdx = -1; + let maxOverlap = 0; + + storeMeta.forEach(m => { + const overlap = calculateTokenOverlap(resp.customer, m.store.customer); + if (overlap > maxOverlap && overlap >= 0.35) { + maxOverlap = overlap; + bestMatchIdx = m.idx; + } + }); + + if (bestMatchIdx !== -1) { + attach(bestMatchIdx, resp); + } + }); + + // PASS 4: Fallback for any remaining unassigned responses to guarantee all visits count + responses.forEach(resp => { + if (assignedResponseIds.has(resp.submissionId)) return; + + const unvisitedCandidate = storeMeta.find(m => !storeResponsesMap.has(m.idx)); + if (unvisitedCandidate) { + attach(unvisitedCandidate.idx, resp); + } else { + attach(0, resp); + } + }); + + return stores.map((store, idx) => { + const storeResponses = storeResponsesMap.get(idx) || []; + + // Sort responses by Submission Date ascending + const sortedResponses = [...storeResponses].sort((a, b) => { + const timeA = parseSubmissionTime(a.submissionDate || a.lastUpdateDate); + const timeB = parseSubmissionTime(b.submissionDate || b.lastUpdateDate); + return timeA - timeB; + }); + + // Take the latest response for this store based on Submission Date + const latestResponse = sortedResponses.length > 0 + ? sortedResponses[sortedResponses.length - 1] + : undefined; + + const isVisited = !!latestResponse; + const hasOOS = isVisited ? latestResponse?.isAvailable === 'No' : false; + + const facingsIceKick = latestResponse?.facingsIceKick || 0; + const facingsBrand = latestResponse?.facingsLucozadeBrand || 0; + const facingsCat = latestResponse?.facingsCategoryTotal || 0; + + const shareOfBrandShelf = facingsBrand > 0 ? (facingsIceKick / facingsBrand) * 100 : 0; + const shareOfCategoryShelf = facingsCat > 0 ? (facingsBrand / facingsCat) * 100 : 0; + + return { + store, + response: latestResponse, + allResponses: sortedResponses, + status: isVisited ? 'Visited' : 'Pending', + hasOOS, + shareOfBrandShelf, + shareOfCategoryShelf + }; + }); +} + +function getCandidateCsvUrls(spreadsheetIdOrUrl: string, sheetName: string): string[] { + let clean = spreadsheetIdOrUrl.trim(); + const urls: string[] = []; + + let pubKey = ''; + if (clean.includes('/d/e/')) { + const match = clean.match(/\/d\/e\/([a-zA-Z0-9-_]+)/); + if (match && match[1]) pubKey = match[1]; + } else if (clean.startsWith('2PACX-')) { + pubKey = clean; + } + + if (pubKey) { + urls.push(`https://docs.google.com/spreadsheets/d/e/${pubKey}/pub?output=csv&sheet=${encodeURIComponent(sheetName)}`); + urls.push(`https://docs.google.com/spreadsheets/d/e/${pubKey}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(sheetName)}`); + return urls; + } + + if (clean.includes('/spreadsheets/d/')) { + const match = clean.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); + if (match && match[1]) clean = match[1]; + } + + urls.push(`https://docs.google.com/spreadsheets/d/${clean}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(sheetName)}`); + urls.push(`https://docs.google.com/spreadsheets/d/${clean}/pub?output=csv&sheet=${encodeURIComponent(sheetName)}`); + + return urls; +} + +// Fetch helper via CSV with pubhtml GID auto-resolution +async function fetchSheetCSV(spreadsheetId: string, sheetName: string): Promise { + let clean = spreadsheetId.trim(); + + // Extract pubKey if /d/e/ format or starts with 2PACX- + let pubKey = ''; + if (clean.includes('/d/e/')) { + const match = clean.match(/\/d\/e\/([a-zA-Z0-9-_]+)/); + if (match && match[1]) pubKey = match[1]; + } else if (clean.startsWith('2PACX-')) { + pubKey = clean; + } + + // If pubKey exists, fetch pubhtml to resolve exact GID for sheetName + if (pubKey) { + try { + const pubHtmlUrl = `https://docs.google.com/spreadsheets/d/e/${pubKey}/pubhtml`; + const htmlRes = await fetch(pubHtmlUrl); + if (htmlRes.ok) { + const html = await htmlRes.text(); + const regex = /items\.push\(\{name:\s*"([^"]+)",[^}]*gid:\s*"([^"]+)"/g; + let match; + let foundGid = ''; + while ((match = regex.exec(html)) !== null) { + if (match[1].trim().toLowerCase() === sheetName.trim().toLowerCase()) { + foundGid = match[2]; + break; + } + } + if (foundGid) { + const directCsvUrl = `https://docs.google.com/spreadsheets/d/e/${pubKey}/pub?gid=${foundGid}&single=true&output=csv`; + const csvRes = await fetch(directCsvUrl); + if (csvRes.ok) { + const csvText = await csvRes.text(); + if (csvText && !csvText.includes(' { + Papa.parse(csvText, { + header: true, + skipEmptyLines: true, + complete: (results) => resolve(results.data), + error: (err) => reject(err) + }); + }); + if (data && data.length > 0) return data; + } + } + } + } + } catch (e) { + console.warn('pubhtml resolution failed, trying fallback candidates...', e); + } + } + + const candidateUrls = getCandidateCsvUrls(spreadsheetId, sheetName); + let lastError: any = null; + + for (const url of candidateUrls) { + try { + const res = await fetch(url); + if (!res.ok) continue; + const csvText = await res.text(); + + if (!csvText || csvText.includes(' { + Papa.parse(csvText, { + header: true, + skipEmptyLines: true, + complete: (results) => resolve(results.data), + error: (err) => reject(err) + }); + }); + + if (data && data.length > 0) { + return data; + } + } catch (err) { + lastError = err; + } + } + + throw new Error(`No se pudo obtener datos CSV de la pesta帽a "${sheetName}".`); +} + +// Fetch helper via Google Sheets API v4 with OAuth token +async function fetchSheetAPIv4(spreadsheetId: string, sheetName: string, accessToken: string): Promise { + const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(sheetName)}?valueRenderOption=FORMATTED_VALUE`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) throw new Error(`Google Sheets API Error: ${res.statusText}`); + const json = await res.json(); + const values: string[][] = json.values || []; + if (values.length < 2) return []; + + const headers = values[0]; + const rows = values.slice(1); + + return rows.map(row => { + const obj: Record = {}; + headers.forEach((h, i) => { + obj[h] = row[i] || ''; + }); + return obj; + }); +} + +export async function fetchGoogleSheetsData( + spreadsheetId: string = DEFAULT_SPREADSHEET_ID, + accessToken?: string | null +): Promise { + const now = new Date().toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + // 1. Attempt API v4 if token exists + if (accessToken) { + try { + const storesRaw = await fetchSheetAPIv4(spreadsheetId, 'tiendas', accessToken); + const responsesRaw = await fetchSheetAPIv4(spreadsheetId, 'Form responses', accessToken); + + const stores = parseStoresSheet(storesRaw); + const responses = parseResponsesSheet(responsesRaw); + const merged = mergeAuditData(stores.length > 0 ? stores : INITIAL_STORES, responses.length > 0 ? responses : INITIAL_RESPONSES); + + return { + stores: stores.length > 0 ? stores : INITIAL_STORES, + responses: responses.length > 0 ? responses : INITIAL_RESPONSES, + merged, + lastSynced: now, + source: 'google_sheets_api' + }; + } catch (e: any) { + console.warn('API v4 fetch failed, trying CSV fallback...', e); + } + } + + // 2. Attempt CSV export + try { + const storesRaw = await fetchSheetCSV(spreadsheetId, 'tiendas'); + const responsesRaw = await fetchSheetCSV(spreadsheetId, 'Form responses'); + + const stores = parseStoresSheet(storesRaw); + const responses = parseResponsesSheet(responsesRaw); + + const finalStores = stores.length > 0 ? stores : INITIAL_STORES; + const finalResponses = responses.length > 0 ? responses : INITIAL_RESPONSES; + const merged = mergeAuditData(finalStores, finalResponses); + + return { + stores: finalStores, + responses: finalResponses, + merged, + lastSynced: now, + source: 'google_sheets_csv' + }; + } catch (e: any) { + console.warn('CSV export fetch failed, using realistic demo dataset:', e); + } + + // 3. Fallback to Initial Demo Data + const merged = mergeAuditData(INITIAL_STORES, INITIAL_RESPONSES); + return { + stores: INITIAL_STORES, + responses: INITIAL_RESPONSES, + merged, + lastSynced: now, + source: 'fallback_demo', + error: 'No se pudo conectar directamente con Google Sheets. Mostrando dataset precargado.' + }; +} diff --git a/src/services/supabaseAuth.ts b/src/services/supabaseAuth.ts new file mode 100644 index 0000000..d1fea3e --- /dev/null +++ b/src/services/supabaseAuth.ts @@ -0,0 +1,451 @@ +export interface AuthUser { + id: string; + email?: string; + user_metadata?: Record; +} + +export interface AuthSession { + access_token: string; + refresh_token: string; + expires_at: number; + token_type?: string; + user: AuthUser; + remember?: boolean; +} + +export interface RecoverySession { + access_token: string; + refresh_token?: string; + expires_at: number; + token_type?: string; + user: AuthUser; +} + +interface AuthResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; + expires_at?: number; + token_type?: string; + user?: AuthUser; +} + +interface N8nAuthResponse { + ok?: boolean; + message?: string; + error?: string; + nextStep?: 'sign_in'; +} + +export interface SignUpResult { + session: AuthSession | null; + message: string; +} + +export interface RecoveryUrlResult { + session: RecoverySession | null; + error: string; +} + +const SUPABASE_URL = (import.meta.env.VITE_SUPABASE_URL || '').replace(/\/$/, ''); +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ''; +const N8N_AUTH_WEBHOOK_URL = import.meta.env.VITE_N8N_AUTH_WEBHOOK_URL || ''; +const LOCAL_SESSION_KEY = 'lucozade-audit-auth-session'; +const TAB_SESSION_KEY = 'lucozade-audit-auth-tab-session'; +const REMEMBERED_EMAIL_KEY = 'lucozade-audit-remembered-email'; + +function ensureSupabaseConfigured(): void { + if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { + throw new Error('Authentication is not configured. Check the Supabase environment variables.'); + } +} + +function ensureAuthWorkflowConfigured(): void { + if (!N8N_AUTH_WEBHOOK_URL) { + throw new Error('The authentication workflow is not configured.'); + } +} + +function getErrorMessage(payload: any, fallback: string): string { + return payload?.msg || payload?.message || payload?.error_description || payload?.error || fallback; +} + +function getCurrentAppUrl(): string { + const basePath = import.meta.env.BASE_URL || '/'; + return new URL(basePath, window.location.origin).toString(); +} + +async function authRequest( + path: string, + init: RequestInit, + accessToken?: string +): Promise { + ensureSupabaseConfigured(); + + const response = await fetch(`${SUPABASE_URL}${path}`, { + ...init, + cache: 'no-store', + headers: { + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${accessToken || SUPABASE_ANON_KEY}`, + 'Content-Type': 'application/json', + ...(init.headers || {}) + } + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Authentication error (${response.status})`)); + } + + return payload as T; +} + +async function requestAuthWorkflow( + action: 'register' | 'recover', + fields: { + fullName?: string; + email: string; + password?: string; + } +): Promise { + ensureAuthWorkflowConfigured(); + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 60_000); + const form = new URLSearchParams({ + action, + email: fields.email.trim().toLowerCase(), + fullName: fields.fullName?.trim() || '', + password: fields.password || '', + redirectUrl: getCurrentAppUrl(), + website: '' + }); + + try { + const response = await fetch(N8N_AUTH_WEBHOOK_URL, { + method: 'POST', + body: form, + signal: controller.signal, + cache: 'no-store', + credentials: 'omit', + mode: 'cors', + headers: { + Accept: 'application/json' + } + }); + + const rawBody = await response.text(); + let payload: N8nAuthResponse = {}; + if (rawBody) { + try { + payload = JSON.parse(rawBody) as N8nAuthResponse; + } catch { + payload = { message: rawBody }; + } + } + + if (!response.ok || payload.ok === false) { + throw new Error(getErrorMessage(payload, `Authentication service error (${response.status})`)); + } + + return payload; + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new Error('The authentication service took too long to respond. Please try again.'); + } + if (error instanceof TypeError || String(error?.message || '').toLowerCase().includes('failed to fetch')) { + throw new Error('The authentication service could not be reached. Please try again.'); + } + throw error; + } finally { + window.clearTimeout(timeout); + } +} + +function normalizeSession(payload: AuthResponse, fallbackUser?: AuthUser): AuthSession | null { + const user = payload.user || fallbackUser; + if (!payload.access_token || !payload.refresh_token || !user) return null; + + return { + access_token: payload.access_token, + refresh_token: payload.refresh_token, + expires_at: payload.expires_at || Math.floor(Date.now() / 1000) + (payload.expires_in || 3600), + token_type: payload.token_type, + user + }; +} + +async function fetchCurrentUser(accessToken: string): Promise { + if (!accessToken) throw new Error('The authentication token is missing.'); + return authRequest('/auth/v1/user', { method: 'GET' }, accessToken); +} + +export async function verifyAppAccess(session: AuthSession): Promise { + ensureSupabaseConfigured(); + + const userId = encodeURIComponent(session.user.id); + const response = await fetch( + `${SUPABASE_URL}/rest/v1/lucozade_access?user_id=eq.${userId}&is_active=eq.true&select=user_id`, + { + method: 'GET', + cache: 'no-store', + headers: { + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${session.access_token}`, + Accept: 'application/json' + } + } + ); + + const payload = await response.json().catch(() => []); + if (!response.ok) { + throw new Error(getErrorMessage(payload, 'Unable to verify access to this application.')); + } + + if (!Array.isArray(payload) || payload.length === 0) { + throw new Error('This account is not authorized to access this application.'); + } +} + +function clearStoredSessions(): void { + localStorage.removeItem(LOCAL_SESSION_KEY); + sessionStorage.removeItem(TAB_SESSION_KEY); +} + +function saveSession(session: AuthSession | null, remember = false): void { + clearStoredSessions(); + if (!session) return; + + const value = JSON.stringify({ ...session, remember }); + if (remember) { + localStorage.setItem(LOCAL_SESSION_KEY, value); + } else { + sessionStorage.setItem(TAB_SESSION_KEY, value); + } +} + +function parseStoredSession(raw: string | null, remember: boolean): AuthSession | null { + if (!raw) return null; + try { + const session = JSON.parse(raw) as AuthSession; + if (!session?.access_token || !session?.refresh_token || !session?.user?.id) return null; + return { ...session, remember }; + } catch { + return null; + } +} + +function readSession(): AuthSession | null { + const tabSession = parseStoredSession(sessionStorage.getItem(TAB_SESSION_KEY), false); + if (tabSession) return tabSession; + + const rememberedSession = parseStoredSession(localStorage.getItem(LOCAL_SESSION_KEY), true); + if (rememberedSession) return rememberedSession; + + clearStoredSessions(); + return null; +} + +export function getRememberedEmail(): string { + return (localStorage.getItem(REMEMBERED_EMAIL_KEY) || '').trim().toLowerCase(); +} + +export function setRememberedEmail(email: string, remember: boolean): void { + if (remember) { + localStorage.setItem(REMEMBERED_EMAIL_KEY, email.trim().toLowerCase()); + } else { + localStorage.removeItem(REMEMBERED_EMAIL_KEY); + } +} + +export async function signInWithPassword( + email: string, + password: string, + remember = false +): Promise { + const normalizedEmail = email.trim().toLowerCase(); + const payload = await authRequest('/auth/v1/token?grant_type=password', { + method: 'POST', + body: JSON.stringify({ email: normalizedEmail, password }) + }); + + const user = payload.user || await fetchCurrentUser(payload.access_token || ''); + const session = normalizeSession(payload, user); + if (!session) throw new Error('The session could not be started.'); + + const finalSession = { ...session, remember }; + try { + await verifyAppAccess(finalSession); + } catch (error) { + clearStoredSessions(); + throw error; + } + + setRememberedEmail(normalizedEmail, remember); + saveSession(finalSession, remember); + return finalSession; +} + +export async function signUpWithPassword( + fullName: string, + email: string, + password: string +): Promise { + const normalizedEmail = email.trim().toLowerCase(); + const workflowResult = await requestAuthWorkflow('register', { + fullName: fullName.trim(), + email: normalizedEmail, + password + }); + + // The workflow creates a confirmed Auth user and grants application access. + // Retry briefly because Auth and PostgREST can take a moment to expose the new row. + let lastError: unknown = null; + for (const delay of [100, 250, 500, 900, 1500, 2500]) { + await new Promise(resolve => window.setTimeout(resolve, delay)); + try { + const session = await signInWithPassword(normalizedEmail, password, false); + return { + session, + message: workflowResult.message || 'Account created successfully.' + }; + } catch (error) { + lastError = error; + } + } + + const detail = lastError instanceof Error ? lastError.message : ''; + return { + session: null, + message: workflowResult.message || detail || 'Account created successfully. Sign in with your email and password.' + }; +} + +export async function sendPasswordReset(email: string): Promise { + const result = await requestAuthWorkflow('recover', { + email: email.trim().toLowerCase() + }); + return result.message || 'If an active account exists, a recovery link has been sent.'; +} + +function getHashParams(): URLSearchParams { + return new URLSearchParams(window.location.hash.replace(/^#/, '')); +} + +export async function getRecoverySessionFromUrl(): Promise { + const params = getHashParams(); + const errorDescription = (params.get('error_description') || params.get('error') || '').trim(); + if (errorDescription) { + return { + session: null, + error: errorDescription + }; + } + + const type = (params.get('type') || '').toLowerCase(); + const accessToken = (params.get('access_token') || '').trim(); + if (type !== 'recovery' || !accessToken) { + return { session: null, error: '' }; + } + + try { + const user = await fetchCurrentUser(accessToken); + const expiresIn = Number(params.get('expires_in') || '3600'); + return { + session: { + access_token: accessToken, + refresh_token: (params.get('refresh_token') || '').trim() || undefined, + token_type: (params.get('token_type') || 'bearer').trim(), + expires_at: Math.floor(Date.now() / 1000) + (Number.isFinite(expiresIn) ? expiresIn : 3600), + user + }, + error: '' + }; + } catch (error: any) { + return { + session: null, + error: error?.message || 'The recovery link is invalid or has expired.' + }; + } +} + +export async function updatePasswordFromRecovery( + recoverySession: RecoverySession, + password: string +): Promise { + if (!recoverySession?.access_token) { + throw new Error('The recovery link is invalid or has expired.'); + } + if (recoverySession.expires_at * 1000 <= Date.now()) { + throw new Error('The recovery link has expired. Request a new one.'); + } + + await authRequest('/auth/v1/user', { + method: 'PUT', + body: JSON.stringify({ password }) + }, recoverySession.access_token); + + try { + await authRequest('/auth/v1/logout', { method: 'POST' }, recoverySession.access_token); + } catch { + // Password was already updated; local recovery data is still cleared below. + } + + clearStoredSessions(); +} + +export function clearAuthActionFromUrl(): void { + window.history.replaceState({}, document.title, `${window.location.pathname}${window.location.search}`); +} + +export async function refreshSession(session: AuthSession): Promise { + try { + const payload = await authRequest('/auth/v1/token?grant_type=refresh_token', { + method: 'POST', + body: JSON.stringify({ refresh_token: session.refresh_token }) + }); + const refreshed = normalizeSession(payload, payload.user || session.user); + if (!refreshed) { + clearStoredSessions(); + return null; + } + + const finalSession = { ...refreshed, remember: Boolean(session.remember) }; + await verifyAppAccess(finalSession); + saveSession(finalSession, Boolean(finalSession.remember)); + return finalSession; + } catch { + clearStoredSessions(); + return null; + } +} + +export async function getStoredSession(): Promise { + const session = readSession(); + if (!session) return null; + + if (session.expires_at * 1000 <= Date.now() + 60_000) { + return refreshSession(session); + } + + try { + await verifyAppAccess(session); + return session; + } catch { + clearStoredSessions(); + return null; + } +} + +export async function signOut(session: AuthSession | null): Promise { + try { + if (session?.access_token) { + await authRequest('/auth/v1/logout', { method: 'POST' }, session.access_token); + } + } catch { + // The local session is still cleared if the network request cannot be completed. + } finally { + clearStoredSessions(); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..df7a750 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,87 @@ +export interface Store { + customer: string; // Customer / Punto de venta (Key) + zone: string; // Zone + address: string; // Address + coordinates?: string; // Coordinates (lat, long) + customerCode: string; // Customer Code + customerChannel: string; // Customer Channel (e.g., Supermercados, Conveniencia) + csSoldIn?: number; // Cs Sold In + unitsSoldIn?: number; // Units Sold In (Sell-In) + lSoldIn?: number; // L Sold In + auditor?: string; // Auditor # + visitDays?: string[]; // Monday, Tuesday, etc. + skuDetail?: string; // SKU detail + assignedDate?: string; // Assigned date (Column 'fecha' in tiendas sheet) + week?: string; // Week column in tiendas sheet (e.g., "Week 29", "Semana 29") +} + +export interface AuditFormResponse { + submissionId: string; + submissionDate: string; // Submission Date + customer: string; // Dynamic Dropdowns (Punto de venta) + isAvailable: 'Yes' | 'No' | string; // Is Lucozade Sport Ice Kick available for sale today? + placementLocation?: string; // Where is the product located? + shelfPhotos?: string[]; // Add photos of the shelf or display + popVisible?: string; // Is promotional (POP) material visible and in good condition? + popPhotos?: string[]; // Add photos of the material POP + retailPrice?: number; // What is the exact retail price on the shelf? + facingsIceKick?: number; // Count the facings of Lucozade Ice Kick. + facingsLucozadeBrand?: number; // Count the facings of the entire Lucozade brand. + facingsCategoryTotal?: number; // Count facings of entire energy and sports drink category + categoryPhotos?: string[]; // Add photos of the Energy & Sports Drinks shelf + skuSubstitution?: string; // Which brand/SKU gave up shelf space and lost facings + totalPhysicalInventory?: number; // Total Physical Inventory (Lucozade Sport Ice Kick) + lastUpdateDate?: string; + week?: string; // Week column in Form responses sheet (e.g., "Week 29", "Semana 29") +} + +export interface MergedStoreAudit { + store: Store; + response?: AuditFormResponse; + allResponses?: AuditFormResponse[]; // All historical responses for this store + status: 'Visited' | 'Pending'; // Visitada / Pendiente + hasOOS: boolean; // True if visited and isAvailable == 'No' + shareOfBrandShelf: number; // (facingsIceKick / facingsLucozadeBrand) * 100 + shareOfCategoryShelf: number; // (facingsLucozadeBrand / facingsCategoryTotal) * 100 +} + +export interface FilterState { + zone: string; + channel: string; + customer: string; + status: 'all' | 'Visited' | 'Pending'; + oosStatus: 'all' | 'Available' | 'OOS'; + search: string; + selectedWeek?: string; // Selected week key ("2026-07-20_2026-07-26" or "all") + dateFrom?: string; + dateTo?: string; +} + +export interface DashboardKPIs { + totalStores: number; + totalResponses: number; // Total registros en Form responses + visitedStores: number; + pendingStores: number; + complianceRate: number; // % Cumplimiento (visitedStores / totalStores * 100) + oosStores: number; // Count of responses with isAvailable == 'No' + availableStores: number; // Count of responses with isAvailable == 'Yes' + oosRate: number; // (oosStores / totalResponses * 100) + numericDistribution: number; // (availableStores / totalResponses * 100) + totalPhysicalInventory: number; // Total physical inventory across visited stores + totalUnitsSoldIn: number; // Total Sell-In across filter + totalSellOutUnits: number; // Total volume sell-out + totalSellOutValue: number; // Total value sell-out + avgRetailPrice: number; // Average price on shelf + avgIceKickFacings: number; // Average Ice Kick facings + avgBrandShelfShare: number; // Avg Share of Brand Shelf % + avgCategoryShelfShare: number; // Avg Share of Category Shelf % + // Placement & Display + mainShelfStores: number; + secondaryDisplayStores: number; + gondolaEndStores: number; + additionalExhibitionStores: number; + mainShelfRate: number; + secondaryDisplayRate: number; + gondolaEndRate: number; + additionalExhibitionRate: number; +} diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts new file mode 100644 index 0000000..2eb8d0e --- /dev/null +++ b/src/utils/dateUtils.ts @@ -0,0 +1,246 @@ +export interface WeekOption { + key: string; // e.g. "2026-07-20_2026-07-26" + label: string; // e.g. "Jul 20 - Jul 26, 2026" + shortLabel: string; // e.g. "Jul 20 - Jul 26" + startDate: string; // "2026-07-20" + endDate: string; // "2026-07-26" +} + +/** + * Parse various date formats into a Javascript Date object. + * Prioritizes YYYY-MM-DD and DD/MM/YYYY formats typical in Spanish/Google Sheets data. + */ +export function parseDateString(dateStr?: string): Date | null { + if (!dateStr || !dateStr.trim()) return null; + const str = dateStr.trim(); + + // 1. Handle Excel/Google Sheets serial date numbers (e.g. 45000 to 55000) + const num = Number(str); + if (!isNaN(num) && num > 30000 && num < 60000) { + const jsTimestamp = (num - 25569) * 86400 * 1000; + const d = new Date(jsTimestamp); + if (!isNaN(d.getTime())) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + } + + // 2. Try YYYY-MM-DD or YYYY/MM/DD (ISO / Standard YMD) + const ymdMatch = str.match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})/); + if (ymdMatch) { + const year = parseInt(ymdMatch[1], 10); + const month = parseInt(ymdMatch[2], 10) - 1; + const day = parseInt(ymdMatch[3], 10); + if (month >= 0 && month <= 11 && day >= 1 && day <= 31) { + return new Date(year, month, day); + } + } + + // 3. Try DD/MM/YYYY or MM/DD/YYYY (or 2-digit years YY e.g. 20/07/26) + const dmyMatch = str.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})/); + if (dmyMatch) { + const p1 = parseInt(dmyMatch[1], 10); + const p2 = parseInt(dmyMatch[2], 10); + let rawYear = parseInt(dmyMatch[3], 10); + if (rawYear < 100) rawYear += 2000; + + let day = p1; + let month = p2 - 1; + + if (p1 > 12) { + // p1 is day (DD/MM/YYYY) + day = p1; + month = p2 - 1; + } else if (p2 > 12) { + // p2 is day (MM/DD/YYYY) + month = p1 - 1; + day = p2; + } else { + // Both <= 12: Default to DD/MM/YYYY (Spanish standard) + day = p1; + month = p2 - 1; + } + + if (month >= 0 && month <= 11 && day >= 1 && day <= 31) { + return new Date(rawYear, month, day); + } + } + + // 4. Fallback: Standard Date.parse + const timestamp = Date.parse(str); + if (!isNaN(timestamp)) { + const d = new Date(timestamp); + if (!isNaN(d.getTime())) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + } + + return null; +} + +/** + * Get the Monday-to-Sunday week range for a given date. + */ +export function getWeekRangeFromDate(dateInput: Date | string): WeekOption | null { + const date = typeof dateInput === 'string' ? parseDateString(dateInput) : dateInput; + if (!date || isNaN(date.getTime())) return null; + + const day = date.getDay(); // 0 = Sunday, 1 = Monday... + const diffToMonday = day === 0 ? -6 : 1 - day; + + const monday = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + monday.setDate(date.getDate() + diffToMonday); + + const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate()); + sunday.setDate(monday.getDate() + 6); + + const pad = (n: number) => (n < 10 ? '0' + n : '' + n); + const startDate = `${monday.getFullYear()}-${pad(monday.getMonth() + 1)}-${pad(monday.getDate())}`; + const endDate = `${sunday.getFullYear()}-${pad(sunday.getMonth() + 1)}-${pad(sunday.getDate())}`; + + const formatMonthDay = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const year = monday.getFullYear(); + + const key = `${startDate}_${endDate}`; + const label = `${formatMonthDay(monday)} - ${formatMonthDay(sunday)}, ${year}`; + const shortLabel = `${formatMonthDay(monday)} - ${formatMonthDay(sunday)}`; + + return { key, label, shortLabel, startDate, endDate }; +} + +/** + * Check if a date string falls within a specific week key ("2026-07-20_2026-07-26"). + */ +export function isDateInWeek(dateStr?: string, weekKey?: string): boolean { + if (!dateStr || !weekKey || weekKey === 'all') return true; + const range = getWeekRangeFromDate(dateStr); + if (!range) return false; + return range.key === weekKey; +} + +/** + * Normalize any week representation (e.g., "30", "30.0", "Semana 30", "semana 30", "Week 30", "W30") + * to a standardized display string "Semana X" (e.g. "Semana 30"). + */ +export function normalizeWeek(weekStr?: string): string { + if (!weekStr) return ''; + const str = weekStr.trim(); + if (!str) return ''; + + const match = str.match(/\d+/); + if (match) { + const num = parseInt(match[0], 10); + if (!isNaN(num) && num > 0 && num < 100) { + return `Semana ${num}`; + } + } + + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Calculate ISO week number from a Date or date string. + */ +export function getWeekNumberFromDate(dateInput: Date | string): number | null { + const d = typeof dateInput === 'string' ? parseDateString(dateInput) : dateInput; + if (!d || isNaN(d.getTime())) return null; + + const target = new Date(d.valueOf()); + const dayNr = (d.getDay() + 6) % 7; + target.setDate(target.getDate() - dayNr + 3); + const firstThursday = target.valueOf(); + target.setMonth(0, 1); + if (target.getDay() !== 4) { + target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7)); + } + return 1 + Math.ceil((firstThursday - target.valueOf()) / 604800000); +} + +/** + * Check if an item (store or response) matches the selected week filter. + * Matches explicit week column value or falls back to date range / ISO week check. + */ +export function isItemInWeek(weekVal?: string, dateVal?: string, selectedWeek?: string): boolean { + if (!selectedWeek || selectedWeek === 'all') return true; + + const normSelected = normalizeWeek(selectedWeek); + const normWeek = normalizeWeek(weekVal); + + if (normWeek && normSelected) { + if (normWeek.toLowerCase() === normSelected.toLowerCase()) { + return true; + } + } + + if (dateVal) { + if (isDateInWeek(dateVal, selectedWeek)) return true; + + const weekNumFromDate = getWeekNumberFromDate(dateVal); + if (weekNumFromDate && normSelected.toLowerCase() === `semana ${weekNumFromDate}`) { + return true; + } + } + + return false; +} + +/** + * Extract all unique week options from stores and form responses. + * Uses explicit 'week' column values if available, or falls back to Monday-Sunday date ranges. + */ +export function extractWeekOptions( + stores: Array<{ assignedDate?: string; week?: string }>, + responses: Array<{ submissionDate?: string; week?: string }> +): WeekOption[] { + const explicitWeeks = new Set(); + + stores.forEach(s => { + const w = normalizeWeek(s.week); + if (w) explicitWeeks.add(w); + }); + + responses.forEach(r => { + const w = normalizeWeek(r.week); + if (w) explicitWeeks.add(w); + }); + + if (explicitWeeks.size > 0) { + const sorted = Array.from(explicitWeeks).sort((a, b) => { + const numA = (a.match(/\d+/) || [])[0] ? parseInt((a.match(/\d+/) || [])[0], 10) : 0; + const numB = (b.match(/\d+/) || [])[0] ? parseInt((b.match(/\d+/) || [])[0], 10) : 0; + if (numA && numB) return numB - numA; + return b.localeCompare(a, undefined, { numeric: true }); + }); + + return sorted.map(w => ({ + key: w, + label: w, + shortLabel: w, + startDate: w, + endDate: w + })); + } + + // Fallback: derive weeks from dates + const map = new Map(); + + stores.forEach(s => { + if (s.assignedDate) { + const option = getWeekRangeFromDate(s.assignedDate); + if (option && !map.has(option.key)) { + map.set(option.key, option); + } + } + }); + + responses.forEach(r => { + if (r.submissionDate) { + const option = getWeekRangeFromDate(r.submissionDate); + if (option && !map.has(option.key)) { + map.set(option.key, option); + } + } + }); + + // Sort weeks descending (latest week first) + return Array.from(map.values()).sort((a, b) => b.startDate.localeCompare(a.startDate)); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..d1c4ecf --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly BASE_URL: string; + readonly VITE_SUPABASE_URL: string; + readonly VITE_SUPABASE_ANON_KEY: string; + readonly VITE_N8N_AUTH_WEBHOOK_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d88f175 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c2ff44c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,30 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +const rootDir = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig(({ command, isPreview }) => ({ + // Local development: http://localhost:3000/ + // Production build: https://digitalcompass.agency/lucozade/ + base: command === 'build' || isPreview === true ? '/lucozade/' : '/', + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(rootDir, '.'), + }, + }, + server: { + host: '0.0.0.0', + port: 3000, + strictPort: true, + hmr: process.env.DISABLE_HMR !== 'true', + }, + preview: { + host: '0.0.0.0', + port: 4173, + strictPort: true, + }, +}));