From 3765e666bc64fb124df9c51b7a6f8a6a14a263ae Mon Sep 17 00:00:00 2001 From: sriram Date: Tue, 11 Aug 2026 19:14:03 +0530 Subject: [PATCH] frontend --- .env.example | 6 + .gitignore | 25 + .oxlintrc.json | 8 + README.md | 64 + index.html | 22 + package-lock.json | 2176 +++++++++++++++++++ package.json | 28 + public/favicon.svg | 1 + public/icons.svg | 24 + src/App.jsx | 108 + src/api/client.js | 183 ++ src/assets/vite.svg | 1 + src/components/Atoms.jsx | 19 + src/components/ChatMessage.jsx | 57 + src/components/ChatPanel.jsx | 112 + src/components/ErrorBoundary.jsx | 48 + src/components/NavigationHeader.jsx | 188 ++ src/components/ProductCard.jsx | 195 ++ src/components/ProductGrid.jsx | 46 + src/components/ProductModal.jsx | 274 +++ src/components/Sidebar.jsx | 100 + src/components/TopBar.jsx | 46 + src/components/UploadExcelModal.jsx | 276 +++ src/components/nutrition/NutritionPanel.jsx | 224 ++ src/context/AuthContext.jsx | 148 ++ src/index.css | 88 + src/main.jsx | 10 + src/pages/AdminPage.jsx | 490 +++++ src/pages/AnalyticsPage.jsx | 249 +++ src/pages/HomePage.jsx | 196 ++ src/pages/LoginPage.jsx | 143 ++ src/pages/NutritionAnalyticsPage.jsx | 216 ++ src/pages/StoresPage.jsx | 229 ++ src/pages/UserPage.jsx | 1004 +++++++++ vite.config.js | 20 + 35 files changed, 7024 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .oxlintrc.json create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/icons.svg create mode 100644 src/App.jsx create mode 100644 src/api/client.js create mode 100644 src/assets/vite.svg create mode 100644 src/components/Atoms.jsx create mode 100644 src/components/ChatMessage.jsx create mode 100644 src/components/ChatPanel.jsx create mode 100644 src/components/ErrorBoundary.jsx create mode 100644 src/components/NavigationHeader.jsx create mode 100644 src/components/ProductCard.jsx create mode 100644 src/components/ProductGrid.jsx create mode 100644 src/components/ProductModal.jsx create mode 100644 src/components/Sidebar.jsx create mode 100644 src/components/TopBar.jsx create mode 100644 src/components/UploadExcelModal.jsx create mode 100644 src/components/nutrition/NutritionPanel.jsx create mode 100644 src/context/AuthContext.jsx create mode 100644 src/index.css create mode 100644 src/main.jsx create mode 100644 src/pages/AdminPage.jsx create mode 100644 src/pages/AnalyticsPage.jsx create mode 100644 src/pages/HomePage.jsx create mode 100644 src/pages/LoginPage.jsx create mode 100644 src/pages/NutritionAnalyticsPage.jsx create mode 100644 src/pages/StoresPage.jsx create mode 100644 src/pages/UserPage.jsx create mode 100644 vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..629d98f --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Leave this empty/unset to use Vite's dev proxy (see vite.config.js), +# which forwards /api/* to http://localhost:8000 with no CORS setup needed. +# +# Set it only if your backend runs somewhere else (a different host/port, +# or in production where the frontend is served separately from the API). +# VITE_API_BASE_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..438657a --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c8382d --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Frontend - Kirana AI (React + Vite) + +React 19 + Vite + Tailwind CSS v4 single-page app for browsing the catalog, +running semantic search, chatting with the RAG assistant, and (on +`/admin`) triggering ingestion of new brands. + +Full setup steps are in the project documentation (`../docs/`). This file +is a fast local reference. + +## Quick start + +### One-click (Windows) + +Double-click **`start_frontend.bat`**. It checks whether the backend is +reachable on port 8000 (just a warning, not a blocker) and starts the +Vite dev server. + +### Manual + +```bash +cd frontend +npm install +npm run dev +``` + +Open http://localhost:5173 - the Vite dev server proxies `/api/*` to +`http://localhost:8000` automatically (see `vite.config.js`), so make +sure the backend is running there first (`../backend/README.md`, +`../backend/start_backend.bat`). + +If your backend runs somewhere else, set `VITE_API_BASE_URL` in a local +`.env` file (copy `.env.example`) instead of relying on the proxy. + +## Production build + +```bash +npm run build # outputs to dist/ +npm run preview # serve the production build locally to sanity-check it +``` + +## Pages + +- `/` - Browse & Search (sidebar brand/category filters, product grid, + semantic search bar) with a toggle to **Ask AI**, a conversational RAG + chat panel that cites which catalog products it used for each answer. +- `/admin` - Trigger ingestion of a new brand and watch job status. + +## Structure + +``` +src/ +├── api/client.js # fetch wrapper for every backend endpoint +├── components/ +│ ├── Sidebar.jsx / TopBar.jsx +│ ├── ProductCard.jsx / ProductGrid.jsx / ProductModal.jsx +│ ├── ChatPanel.jsx / ChatMessage.jsx +│ └── Atoms.jsx # Spinner, EmptyState +├── pages/ +│ ├── HomePage.jsx +│ └── AdminPage.jsx +├── App.jsx # routes +├── main.jsx +└── index.css # Tailwind v4 theme tokens ("Kirana Ledger" palette) +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..0a39d5d --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + Brand Catalog - Product LLM + + + + +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3b29d8d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2176 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^1.21.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.69.0", + "tailwindcss": "^4.3.1", + "vite": "^8.1.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.71.0.tgz", + "integrity": "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.71.0.tgz", + "integrity": "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.71.0.tgz", + "integrity": "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.71.0.tgz", + "integrity": "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.71.0.tgz", + "integrity": "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.71.0.tgz", + "integrity": "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.71.0.tgz", + "integrity": "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.71.0.tgz", + "integrity": "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.71.0.tgz", + "integrity": "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.71.0.tgz", + "integrity": "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.71.0.tgz", + "integrity": "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.71.0.tgz", + "integrity": "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.71.0.tgz", + "integrity": "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.71.0.tgz", + "integrity": "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.71.0.tgz", + "integrity": "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.71.0.tgz", + "integrity": "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.71.0.tgz", + "integrity": "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.71.0.tgz", + "integrity": "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "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.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@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.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "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/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "MIT" + }, + "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, + "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, + "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, + "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, + "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/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "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/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oxlint": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", + "integrity": "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.71.0", + "@oxlint/binding-android-arm64": "1.71.0", + "@oxlint/binding-darwin-arm64": "1.71.0", + "@oxlint/binding-darwin-x64": "1.71.0", + "@oxlint/binding-freebsd-x64": "1.71.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", + "@oxlint/binding-linux-arm-musleabihf": "1.71.0", + "@oxlint/binding-linux-arm64-gnu": "1.71.0", + "@oxlint/binding-linux-arm64-musl": "1.71.0", + "@oxlint/binding-linux-ppc64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-musl": "1.71.0", + "@oxlint/binding-linux-s390x-gnu": "1.71.0", + "@oxlint/binding-linux-x64-gnu": "1.71.0", + "@oxlint/binding-linux-x64-musl": "1.71.0", + "@oxlint/binding-openharmony-arm64": "1.71.0", + "@oxlint/binding-win32-arm64-msvc": "1.71.0", + "@oxlint/binding-win32-ia32-msvc": "1.71.0", + "@oxlint/binding-win32-x64-msvc": "1.71.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "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/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ab8323 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.21.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.69.0", + "tailwindcss": "^4.3.1", + "vite": "^8.1.0" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..6601a3f --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { LoginPage } from './pages/LoginPage'; +import { HomePage } from './pages/HomePage'; +import { UserPage } from './pages/UserPage'; +import { AdminPage } from './pages/AdminPage'; +import { StoresPage } from './pages/StoresPage'; +import { AnalyticsPage } from './pages/AnalyticsPage'; +import { NutritionAnalyticsPage } from './pages/NutritionAnalyticsPage'; + +function ProtectedRoute({ children, allowedRoles }) { + const { user, role } = useAuth(); + + if (!user) { + return ; + } + + const effectiveRole = role || (user.role === 'store' ? 'user' : user.role); + + if (allowedRoles && !allowedRoles.includes(effectiveRole)) { + if (window.location.pathname === '/user') { + return children; + } + return ; + } + + return children; +} + +export default function App() { + return ( + + + + + {/* Public Login Route */} + } /> + + {/* Catalog Route - RESTRICTED TO ADMIN ONLY */} + + + + } + /> + + {/* User Workspace Route */} + + + + } + /> + + {/* Admin Panel Route - RESTRICTED TO ADMIN ONLY */} + + + + } + /> + + {/* Store Intelligence Route */} + + + + } + /> + + {/* Analytics Route */} + + + + } + /> + + {/* Nutrition Intelligence Route */} + + + + } + /> + + {/* Catch-all redirect */} + } /> + + + + + ); +} diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..2c94faa --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,183 @@ +const BASE = import.meta.env.VITE_API_BASE_URL || ''; + +class ApiError extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} + +async function request(path, options = {}) { + let res; + try { + res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + ...options, + }); + } catch { + throw new ApiError( + `Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` + + `(uvicorn app.main:app) and reachable?`, + 0 + ); + } + + if (!res.ok) { + let detail = `Request failed (${res.status})`; + try { + const body = await res.json(); + detail = body.detail || JSON.stringify(body); + } catch { + /* ignore parse errors, keep generic message */ + } + throw new ApiError(detail, res.status); + } + + if (res.status === 204) return null; + return res.json(); +} + +function qs(params = {}) { + const usp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') usp.set(k, v); + }); + const s = usp.toString(); + return s ? `?${s}` : ''; +} + +// Browse endpoints intentionally request a very high `limit` explicitly +// (rather than relying on the backend's default) so that the UI always +// shows the FULL catalog for a brand / for "All brands", however many +// thousands of products that ends up being. Do not lower this to a small +// page size unless you also add real pagination controls to the grid - +// silently truncating the product list here was the exact bug this +// constant is guarding against. +const SHOW_ALL_PRODUCTS_LIMIT = 100000; + +export const api = { + getHealth: () => request('/api/health'), + + getBrands: () => request('/api/brands'), + + getBrandCategories: (brand) => request(`/api/brands/${encodeURIComponent(brand)}/categories`), + + getBrandProducts: (brand, { category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) => + request(`/api/brands/${encodeURIComponent(brand)}/products${qs({ category, limit, offset })}`), + + getAllProducts: ({ category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) => + request(`/api/products${qs({ category, limit, offset })}`), + + getProductDetail: (brand, imageId) => + request(`/api/brands/${encodeURIComponent(brand)}/products/${encodeURIComponent(imageId)}`), + + search: (q, { brand, category, top_k = 50 } = {}) => + request(`/api/search${qs({ q, brand, category, top_k })}`), + + chat: ({ query, brand, category, top_k, history }) => + request('/api/chat', { + method: 'POST', + body: JSON.stringify({ query, brand, category, top_k, history }), + }), + + generateCatalog: ({ brand, max_products = 50 }) => + request('/api/catalog/generate', { + method: 'POST', + body: JSON.stringify({ brand, max_products }), + }), + + getJobStatus: (jobId) => request(`/api/catalog/jobs/${jobId}`), + + // --- Store Intelligence (v3.0) --- + getStores: () => request('/api/stores'), + getStore: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}`), + getStoreProducts: (storeId, { category, in_stock_only, limit = 500, offset = 0 } = {}) => + request(`/api/stores/${encodeURIComponent(storeId)}/products${qs({ category, in_stock_only, limit, offset })}`), + getProductAcrossStores: (brand, imageId) => + request(`/api/products/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/stores`), + + getStoreDiscounts: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}/discounts`), + getProductDiscount: (storeId, brand, imageId) => + request(`/api/stores/${encodeURIComponent(storeId)}/discounts/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`), + + getTrending: ({ window = 'weekly', scope = 'overall', scope_value, top_k = 10 } = {}) => + request(`/api/trending${qs({ window, scope, scope_value, top_k })}`), + + getRecommendations: (brand, imageId, topK = 5) => + request(`/api/recommendations/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}${qs({ top_k: topK })}`), + + getStoreAnalytics: (storeId) => request(`/api/analytics/store/${encodeURIComponent(storeId)}`), + getStoreComparison: () => request('/api/analytics/compare'), + getProductAnalytics: (brand, imageId) => request(`/api/analytics/product/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`), + getTopProducts: ({ by = 'revenue', order = 'top', limit = 10 } = {}) => + request(`/api/analytics/top-products${qs({ by, order, limit })}`), + + seedStoreIntelligence: ({ reset_orders = true, days = 90, seed = 42 } = {}) => + request('/api/admin/store-intelligence/seed', { method: 'POST', body: JSON.stringify({ reset_orders, days, seed }) }), + trainStoreModels: ({ models } = {}) => + request('/api/admin/store-intelligence/train', { method: 'POST', body: JSON.stringify({ models: models || null }) }), + getStoreIntelligenceJob: (jobId) => request(`/api/admin/store-intelligence/jobs/${jobId}`), + + // --- AI Nutritional Intelligence Module --- + getNutrition: (brand, imageId) => + request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`), + getNutritionHealthScore: (brand, imageId) => + request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/health-score`), + getNutritionInsights: (brand, imageId) => + request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/insights`), + getNutritionAlternatives: (brand, imageId, topK = 5) => + request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/alternatives${qs({ top_k: topK })}`), + getNutritionSimilar: (brand, imageId, topK = 5) => + request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/similar${qs({ top_k: topK })}`), + compareNutrition: (products) => request(`/api/nutrition/compare${qs({ products: products.join(',') })}`), + + getDietCompatibleProducts: (tag, { category, exclude_allergen, limit = 20, offset = 0 } = {}) => + request(`/api/nutrition/diet/${encodeURIComponent(tag)}${qs({ category, exclude_allergen, limit, offset })}`), + getHighProteinProducts: ({ category, limit = 20, offset = 0 } = {}) => + request(`/api/nutrition/high-protein${qs({ category, limit, offset })}`), + getLowSugarProducts: ({ category, limit = 20, offset = 0 } = {}) => + request(`/api/nutrition/low-sugar${qs({ category, limit, offset })}`), + getHighFiberProducts: ({ category, limit = 20, offset = 0 } = {}) => + request(`/api/nutrition/high-fiber${qs({ category, limit, offset })}`), + filterNutritionProducts: ({ sort_by = 'health_score', order = 'desc', category, diet_tag, exclude_allergen, limit = 20, offset = 0 } = {}) => + request(`/api/nutrition/products${qs({ sort_by, order, category, diet_tag, exclude_allergen, limit, offset })}`), + + getNutritionAnalyticsDashboard: (limit = 10) => request(`/api/nutrition/analytics/dashboard${qs({ limit })}`), + getNutritionLeaderboards: (limit = 10) => request(`/api/nutrition/analytics/leaderboards${qs({ limit })}`), + getNutritionRankings: (limit = 10) => request(`/api/nutrition/analytics/rankings${qs({ limit })}`), + getNutritionDistribution: () => request('/api/nutrition/analytics/distribution'), + + getPersonalizedNutritionRecommendations: (customerId, topK = 8) => + request(`/api/nutrition/recommendations/${encodeURIComponent(customerId)}${qs({ top_k: topK })}`), + + enrichNutrition: ({ skip_if_verified = true, generate_narrative = true, max_products } = {}) => + request('/api/admin/nutrition-intelligence/enrich', { + method: 'POST', body: JSON.stringify({ skip_if_verified, generate_narrative, max_products: max_products || null }), + }), + trainNutritionModels: () => request('/api/admin/nutrition-intelligence/train', { method: 'POST', body: JSON.stringify({}) }), + getNutritionIntelligenceJob: (jobId) => request(`/api/admin/nutrition-intelligence/jobs/${jobId}`), + getNutritionEnrichmentStatus: () => request('/api/admin/nutrition-intelligence/status'), + + // --- Excel / CSV Upload API --- + uploadFile: async (tabType, file) => { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch(`${BASE}/api/upload/${encodeURIComponent(tabType)}`, { + method: 'POST', + body: formData, + }); + if (!res.ok) { + let detail = `Upload failed (${res.status})`; + try { + const body = await res.json(); + detail = body.detail || JSON.stringify(body); + } catch {} + throw new ApiError(detail, res.status); + } + return res.json(); + }, + + getTemplateUrl: (tabType) => `${BASE}/api/upload/template/${encodeURIComponent(tabType)}`, +}; + +export { ApiError }; diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/Atoms.jsx b/src/components/Atoms.jsx new file mode 100644 index 0000000..d746519 --- /dev/null +++ b/src/components/Atoms.jsx @@ -0,0 +1,19 @@ +export function Spinner({ label = 'Loading…', className = '' }) { + return ( +
+ + {label} +
+ ); +} + +export function EmptyState({ icon: Icon, title, subtitle, action }) { + return ( +
+ {Icon && } +

{title}

+ {subtitle &&

{subtitle}

} + {action} +
+ ); +} diff --git a/src/components/ChatMessage.jsx b/src/components/ChatMessage.jsx new file mode 100644 index 0000000..f984e99 --- /dev/null +++ b/src/components/ChatMessage.jsx @@ -0,0 +1,57 @@ +import { Bot, User, Receipt, Tag } from 'lucide-react'; + +export function ChatMessage({ role, content, sources = [], detectedCategory, onSelectSource }) { + const isUser = role === 'user'; + + if (isUser) { + return ( +
+
+

{content}

+ + + +
+
+ ); + } + + return ( +
+
+ + + +
+ {detectedCategory && ( +
+ Searched in: {detectedCategory} +
+ )} +

{content}

+ + {sources.length > 0 && ( + <> +
+
+ Sourced from {sources.length} catalog item{sources.length > 1 ? 's' : ''} +
+
+ {sources.map((s) => ( + + ))} +
+ + )} +
+
+
+ ); +} diff --git a/src/components/ChatPanel.jsx b/src/components/ChatPanel.jsx new file mode 100644 index 0000000..2e734e1 --- /dev/null +++ b/src/components/ChatPanel.jsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react'; +import { SendHorizontal, Sparkles } from 'lucide-react'; +import { ChatMessage } from './ChatMessage'; +import { Spinner } from './Atoms'; +import { api } from '../api/client'; + +const SUGGESTIONS = [ + 'Recommend a low sugar biscuit', + 'What snacks come in family packs?', + 'Show me something under ₹20', + 'Any options with no added preservatives?', +]; + +export function ChatPanel({ brand, onSelectSource }) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages, loading]); + + async function send(text) { + const query = (text ?? input).trim(); + if (!query || loading) return; + + setInput(''); + setError(null); + const history = messages.map((m) => ({ role: m.role, content: m.content })); + setMessages((prev) => [...prev, { role: 'user', content: query }]); + setLoading(true); + + try { + const res = await api.chat({ query, brand: brand || undefined, history }); + setMessages((prev) => [...prev, { role: 'assistant', content: res.answer, sources: res.sources, detectedCategory: res.detected_category }]); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {messages.length === 0 && ( +
+ + + +
+

Ask about the catalog

+

+ I'll answer using only the products actually in the catalog + {brand ? ` for ${brand}` : ''}, and show you exactly which ones I used. +

+
+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ )} + + {messages.map((m, i) => ( + + ))} + + {loading && ( +
+ +
+ )} + + {error && ( +

{error}

+ )} +
+ +
{ + e.preventDefault(); + send(); + }} + className="flex items-center gap-2 border-t border-ink-900/10 bg-paper-50 px-4 py-3" + > + setInput(e.target.value)} + placeholder={brand ? `Ask about ${brand}'s products…` : 'Ask about any product in the catalog…'} + className="flex-1 rounded-full border border-ink-900/10 bg-white px-4 py-2.5 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20" + /> + +
+
+ ); +} diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..6f6f045 --- /dev/null +++ b/src/components/ErrorBoundary.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +export class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+
+
+

Something went wrong in this section

+

+ {this.state.error?.message || 'An unexpected rendering error occurred.'} +

+
+ +
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/NavigationHeader.jsx b/src/components/NavigationHeader.jsx new file mode 100644 index 0000000..778e627 --- /dev/null +++ b/src/components/NavigationHeader.jsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid } from 'lucide-react'; +import { useAuth } from '../context/AuthContext'; + +export function NavigationHeader({ + title, + subtitle, + icon: TitleIcon, + health, + onResetHome, + children, +}) { + const location = useLocation(); + const path = location.pathname; + const search = location.search; + const navigate = useNavigate(); + const { user, role, logout } = useAuth(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const getRoleBadge = () => { + if (role === 'admin') { + return ( + + Admin + + ); + } + return ( + + User + + ); + }; + + return ( +
+
+ {/* Left Section: Home/Back Button + Page Title */} +
+ { + if (onResetHome) onResetHome(); + }} + title={role === 'admin' ? 'Navigate to Catalog' : 'Navigate to User Workspace'} + className="group flex items-center gap-2 rounded-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white px-4 py-2 text-xs font-semibold shadow-sm transition-all duration-200 active:scale-95 hover:shadow-md" + > + + {role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'} + + + {title && ( +
+ {TitleIcon && ( +
+ +
+ )} +
+

+ {title} +

+ {subtitle &&

{subtitle}

} +
+
+ )} +
+ + {/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */} +
+ {health && health.status !== 'ok' && ( + + + {!health.database && !health.ollama + ? 'DB & Ollama offline' + : !health.database + ? 'Database offline' + : 'Ollama offline'} + + )} + + {/* User Role Profile Badge */} +
+ {user?.display_name || user?.username || 'Guest'} + {getRoleBadge()} +
+ + {/* Role-tailored Navigation Tabs */} + + + {/* Top Right Logout Button */} + +
+
+ + {children} +
+ ); +} diff --git a/src/components/ProductCard.jsx b/src/components/ProductCard.jsx new file mode 100644 index 0000000..ef21c13 --- /dev/null +++ b/src/components/ProductCard.jsx @@ -0,0 +1,195 @@ +import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode, Images } from 'lucide-react'; +import { useState, useMemo } from 'react'; + +const FOOD_CATEGORIES = [ + 'biscuit', 'cookie', 'cracker', 'rusk', + 'cake', 'muffin', 'bakery', 'bread', + 'dairy', 'milk', 'cheese', 'butter', 'ghee', 'paneer', 'yogurt', + 'chocolate', 'cocoa', + 'snack', 'chips', 'namkeen', + 'noodle', 'pasta', 'maggi', + 'grain', 'rice', 'wheat', 'flour', 'atta', + 'oil', 'cooking oil', + 'spice', 'masala', + 'beverage', 'tea', 'coffee', 'juice', 'drink', + 'jam', 'spread', 'honey', + 'sauce', 'ketchup', 'pickle', + 'frozen food', + 'nutrition', 'health drink', 'cereal', + 'baby food', + 'food', +]; + +function isFoodCategory(category) { + if (!category || typeof category !== 'string') return false; + const cat = category.toLowerCase(); + return FOOD_CATEGORIES.some((kw) => cat.includes(kw)); +} + +export function ProductCard({ product, onClick, similarity }) { + if (!product) return null; + + const { + product_name = 'Product', + brand = 'Brand', + category = '', + price_range, + highlights = [], + providers = [], + image_url, + image_urls = [], + nutrients = [], + fssai_license, + product_sku, + sku_source, + hsn_code, + final_selling_price, + selling_price, + barcode, + barcode_type + } = product; + + const displayHsnCode = hsn_code || product.hsnCode || null; + + const allImages = useMemo(() => { + const list = []; + if (image_url && typeof image_url === 'string') list.push(image_url); + if (Array.isArray(image_urls)) { + for (const u of image_urls) { + if (u && typeof u === 'string' && !list.includes(u)) list.push(u); + } + } + return list; + }, [image_url, image_urls]); + + const [imgIndex, setImgIndex] = useState(0); + const [imgError, setImgError] = useState(false); + + const showNutrients = useMemo(() => isFoodCategory(category) && Array.isArray(nutrients) && nutrients.length > 0, [category, nutrients]); + const imageCount = allImages.length; + const currentDisplayUrl = allImages[imgIndex]; + const displayPrice = final_selling_price ?? selling_price; + + const handleImgError = () => { + if (imgIndex + 1 < allImages.length) { + setImgIndex((prev) => prev + 1); + } else { + setImgError(true); + } + }; + + return ( + + ); +} diff --git a/src/components/ProductGrid.jsx b/src/components/ProductGrid.jsx new file mode 100644 index 0000000..d23b314 --- /dev/null +++ b/src/components/ProductGrid.jsx @@ -0,0 +1,46 @@ +import { PackageSearch } from 'lucide-react'; +import { ProductCard } from './ProductCard'; +import { Spinner, EmptyState } from './Atoms'; + +export function ProductGrid({ products, loading, error, emptyTitle, emptySubtitle, onSelect, showSimilarity }) { + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + if (!products || products.length === 0) { + return ( + + ); + } + + return ( +
+ {products.map((p) => ( + onSelect?.(p)} + /> + ))} +
+ ); +} diff --git a/src/components/ProductModal.jsx b/src/components/ProductModal.jsx new file mode 100644 index 0000000..c00180c --- /dev/null +++ b/src/components/ProductModal.jsx @@ -0,0 +1,274 @@ +import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images, ShieldCheck } from 'lucide-react'; +import { useEffect, useState, useMemo } from 'react'; +import { api } from '../api/client'; +import { NutritionPanel } from './nutrition/NutritionPanel'; + +export function ProductModal({ product, onClose, onSelectRecommendation }) { + const [currentImgIndex, setCurrentImgIndex] = useState(0); + const [failedIndices, setFailedIndices] = useState(new Set()); + const [recommendations, setRecommendations] = useState(null); + const [recsLoading, setRecsLoading] = useState(false); + + const images = useMemo(() => { + let list = []; + if (Array.isArray(product?.image_urls) && product.image_urls.length > 0) { + list = product.image_urls.filter(Boolean); + } else if (product?.image_url) { + list = [product.image_url]; + } + return list; + }, [product]); + + useEffect(() => { + setCurrentImgIndex(0); + setFailedIndices(new Set()); + setRecommendations(null); + if (!product?.brand || !product?.image_id) return; + setRecsLoading(true); + api.getRecommendations(product.brand, product.image_id, 5) + .then(setRecommendations) + .catch(() => setRecommendations([])) + .finally(() => setRecsLoading(false)); + }, [product?.brand, product?.image_id]); + + useEffect(() => { + if (!product || images.length <= 1) return; + const handleKeyDown = (e) => { + if (e.key === 'ArrowLeft') { + setCurrentImgIndex((prev) => (prev - 1 + images.length) % images.length); + } else if (e.key === 'ArrowRight') { + setCurrentImgIndex((prev) => (prev + 1) % images.length); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [product, images]); + + if (!product) return null; + const { + product_name, brand, category, description, price_range, + size_variants = [], providers = [], highlights = [], nutrients = [], + fssai_license, product_sku, sku_source, hsn_code, final_selling_price, selling_price, + barcode, barcode_type, + } = product; + + const currentUrl = images[currentImgIndex]; + const isCurrentFailed = failedIndices.has(currentImgIndex); + const displayPrice = final_selling_price ?? selling_price; + + const handlePrev = (e) => { + e.stopPropagation(); + setCurrentImgIndex((prev) => (prev - 1 + images.length) % images.length); + }; + + const handleNext = (e) => { + e.stopPropagation(); + setCurrentImgIndex((prev) => (prev + 1) % images.length); + }; + + return ( +
+
e.stopPropagation()}> + {/* Close Button */} + + + {/* Product Image Section / Carousel */} + {images.length > 0 ? ( +
+
+ {currentUrl && !isCurrentFailed ? ( + {`${product_name} setFailedIndices((prev) => new Set(prev).add(currentImgIndex))} + /> + ) : ( +
+ +
+ )} + + {/* Counter / Info Pill */} +
+ + {currentImgIndex + 1} / {images.length} +
+ + {/* Carousel Left '<' and Right '>' Navigation Buttons */} + {images.length > 1 && ( + <> + + + + )} +
+ + {/* Thumbnail Navigation Strip */} + {images.length > 1 && ( +
+ {images.map((url, idx) => ( + + ))} +
+ )} +
+ ) : null} + +

+ {brand} {category ? `· ${category}` : ''} +

+

{product_name}

+
+ {barcode && ( +
+ + Barcode: {barcode}{barcode_type ? ` (${barcode_type})` : ''} +
+ )} + {product_sku && product_sku !== barcode && ( +
+ {!barcode && } + SKU: {product_sku}{sku_source ? ` · ${sku_source}` : ''} +
+ )} + {hsn_code && ( + + HSN: {hsn_code} + + )} + {fssai_license && ( + + + FSSAI: {fssai_license} + + )} +
+ + {displayPrice !== undefined && displayPrice !== null ? ( +
+

Final Price: ₹{displayPrice}

+ {price_range && price_range !== `₹${displayPrice}` && ( + ({price_range}) + )} +
+ ) : price_range ? ( +

{price_range}

+ ) : null} + + {description &&

{description}

} + + + + {size_variants.length > 0 && ( +
+
+ {size_variants.map((s, i) => ( + {s} + ))} +
+
+ )} + + {highlights.length > 0 && ( +
+
+ {highlights.map((h, i) => ( + {h} + ))} +
+
+ )} + + {nutrients.length > 0 && ( +
+
    + {nutrients.map((n, i) =>
  • {n.split(' - ')[0]}
  • )} +
+
+ )} + + {providers.length > 0 && ( +
+
+ {providers.map((p, i) => ( + {p} + ))} +
+
+ )} + + {(recsLoading || (recommendations && recommendations.length > 0)) && ( +
+ {recsLoading ? ( +

Finding similar products…

+ ) : ( +
+ {recommendations.map((r) => ( + + ))} +
+ )} +
+ )} +
+
+ ); +} + +function prettifyImageId(imageId) { + if (!imageId) return ''; + const parts = imageId.split('_').filter((p, i, arr) => !(i > 0 && arr[i - 1] === p)); // drop immediate repeated tokens (e.g. brand_brand_...) + return parts.join(' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function Section({ icon: Icon, label, children }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx new file mode 100644 index 0000000..4d8f15c --- /dev/null +++ b/src/components/Sidebar.jsx @@ -0,0 +1,100 @@ +import { ShoppingBasket, Tag, RefreshCw } from 'lucide-react'; +import { Spinner } from './Atoms'; + +export function Sidebar({ + brands, brandsLoading, + categories, categoriesLoading, + selectedBrand, selectedCategory, + onSelectBrand, onSelectCategory, + onRefreshBrands, +}) { + return ( + + ); +} diff --git a/src/components/TopBar.jsx b/src/components/TopBar.jsx new file mode 100644 index 0000000..694bb6a --- /dev/null +++ b/src/components/TopBar.jsx @@ -0,0 +1,46 @@ +import { Search, MessageCircle, LayoutGrid } from 'lucide-react'; +import { NavigationHeader } from './NavigationHeader'; + +export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health, onResetHome }) { + return ( + +
+
+ onModeChange('search')} /> + onModeChange('chat')} /> +
+ + {mode === 'search' && ( +
{ + e.preventDefault(); + onSubmit(); + }} + className="relative flex-1 w-full" + > + + onQueryChange(e.target.value)} + placeholder="Search catalog products — e.g. 'Recommend Paneer under ₹150', 'How many products in Cadbury?'" + className="w-full rounded-full border border-ink-900/10 bg-white py-2 pl-10 pr-4 text-xs text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 shadow-xs" + /> + + )} +
+
+ ); +} + +function ModeButton({ icon: Icon, label, active, onClick }) { + return ( + + ); +} diff --git a/src/components/UploadExcelModal.jsx b/src/components/UploadExcelModal.jsx new file mode 100644 index 0000000..1293c2b --- /dev/null +++ b/src/components/UploadExcelModal.jsx @@ -0,0 +1,276 @@ +import { useState, useRef } from 'react'; +import { X, UploadCloud, Download, FileSpreadsheet, CheckCircle2, AlertCircle, Loader2, Table } from 'lucide-react'; +import { api } from '../api/client'; + +export function UploadExcelModal({ isOpen, onClose, tabType, tabTitle, onSuccess }) { + const [file, setFile] = useState(null); + const [previewRows, setPreviewRows] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + if (!isOpen) return null; + + const parsePreview = (selectedFile) => { + setResult(null); + setError(null); + setFile(selectedFile); + + // Generate quick client-side text preview for CSV/TSV/Text + if (selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.tsv') || selectedFile.name.endsWith('.txt')) { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result || ''; + const lines = text.split('\n').filter((l) => l.trim().length > 0); + if (lines.length > 0) { + const headers = lines[0].split(/,|\t/).map((h) => h.trim().replace(/^["']|["']$/g, '')); + const rows = lines.slice(1, 6).map((line) => + line.split(/,|\t/).map((c) => c.trim().replace(/^["']|["']$/g, '')) + ); + setPreviewRows({ headers, rows, totalLines: lines.length - 1 }); + } + }; + reader.readAsText(selectedFile.slice(0, 1024 * 10)); // read first 10KB + } else { + // Excel binary preview placeholder + setPreviewRows({ headers: ['Excel File Data Detected'], rows: [[selectedFile.name, `${(selectedFile.size / 1024).toFixed(1)} KB`]], totalLines: 'Excel' }); + } + }; + + const handleDragOver = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + const droppedFile = e.dataTransfer.files[0]; + parsePreview(droppedFile); + } + }; + + const handleFileChange = (e) => { + if (e.target.files && e.target.files[0]) { + parsePreview(e.target.files[0]); + } + }; + + const handleUpload = async () => { + if (!file) return; + setLoading(true); + setError(null); + setResult(null); + + try { + const res = await api.uploadFile(tabType, file); + setResult(res); + if (onSuccess) onSuccess(res); + } catch (err) { + setError(err.message || 'Failed to import file. Please check file format.'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setFile(null); + setPreviewRows(null); + setResult(null); + setError(null); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

+ Upload {tabTitle || 'Excel / CSV File'} +

+

+ Import `.xlsx`, `.xls`, or `.csv` records into the database +

+
+
+ +
+ + {/* Template Download Option */} +
+
+

Need a sample format?

+

Download pre-formatted CSV template with example columns.

+
+ + + Sample CSV + +
+ + {/* Drag and Drop Zone */} +
fileInputRef.current?.click()} + className={`mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-6 text-center transition-all ${ + isDragging + ? 'border-amber-500 bg-amber-500/10 scale-[1.01]' + : file + ? 'border-leaf-500/60 bg-leaf-500/5' + : 'border-ink-900/15 bg-white hover:border-amber-400 hover:bg-slate-50' + }`} + > + + +
+ +
+ + {file ? ( +
+

{file.name}

+

+ {(file.size / 1024).toFixed(1)} KB · Click or drag another file to change +

+
+ ) : ( +
+

+ Click to browse or drag & drop Excel / CSV file +

+

+ Supports `.xlsx`, `.xls`, `.csv`, `.tsv` +

+
+ )} +
+ + {/* File Preview Table */} + {previewRows && ( +
+
+ + + Data Preview (First 5 Rows) + + {typeof previewRows.totalLines === 'number' && ( + + {previewRows.totalLines} total data rows detected + + )} + + +
+
+ + + {previewRows.headers.map((h, idx) => ( + + ))} + + + + {previewRows.rows.map((row, rIdx) => ( + + {row.map((cell, cIdx) => ( + + ))} + + ))} + +
{h}
{cell}
+
+
+ )} + + {/* Error Alert */} + {error && ( +
+ + {error} +
+ )} + + {/* Success Alert */} + {result && ( +
+ +
+

{result.message}

+

+ File `{result.filename}` ({result.rows_imported} rows) imported successfully. +

+
+
+ )} + + {/* Action Buttons */} +
+ + + {!result && ( + + )} +
+
+
+ ); +} diff --git a/src/components/nutrition/NutritionPanel.jsx b/src/components/nutrition/NutritionPanel.jsx new file mode 100644 index 0000000..3395f24 --- /dev/null +++ b/src/components/nutrition/NutritionPanel.jsx @@ -0,0 +1,224 @@ +import { Activity, AlertTriangle, ArrowRightLeft, CheckCircle2, ShieldAlert, Sparkles } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { api } from '../../api/client'; +import { Section } from '../ProductModal'; +import { Spinner } from '../Atoms'; + +const CORE_NUTRIENTS = [ + ['calories_kcal', 'Calories', 'kcal', 0], + ['protein_g', 'Protein', 'g', 1], + ['carbohydrates_g', 'Carbohydrates', 'g', 1], + ['total_sugar_g', 'Total Sugar', 'g', 1], + ['added_sugar_g', 'Added Sugar', 'g', 1], + ['dietary_fiber_g', 'Dietary Fiber', 'g', 1], + ['total_fat_g', 'Total Fat', 'g', 1], + ['saturated_fat_g', 'Saturated Fat', 'g', 1], + ['trans_fat_g', 'Trans Fat', 'g', 2], + ['cholesterol_mg', 'Cholesterol', 'mg', 0], + ['sodium_mg', 'Sodium', 'mg', 0], + ['potassium_mg', 'Potassium', 'mg', 0], + ['calcium_mg', 'Calcium', 'mg', 0], + ['iron_mg', 'Iron', 'mg', 1], + ['magnesium_mg', 'Magnesium', 'mg', 0], + ['zinc_mg', 'Zinc', 'mg', 1], + ['vitamin_a_mcg', 'Vitamin A', 'mcg', 0], + ['vitamin_c_mg', 'Vitamin C', 'mg', 1], + ['vitamin_d_mcg', 'Vitamin D', 'mcg', 1], + ['vitamin_e_mg', 'Vitamin E', 'mg', 1], + ['omega_3_g', 'Omega-3', 'g', 2], + ['omega_6_g', 'Omega-6', 'g', 2], +]; + +function scoreColor(score) { + if (score === null || score === undefined) return 'bg-ink-100 text-slate-500'; + if (score >= 70) return 'bg-leaf-100 text-leaf-700'; + if (score >= 40) return 'bg-amber-100 text-amber-700'; + return 'bg-red-100 text-red-700'; +} + +function ScoreBadge({ label, score }) { + return ( +
+ {score ?? '–'} + {label} +
+ ); +} + +export function NutritionPanel({ brand, imageId, productName }) { + const [nutrition, setNutrition] = useState(null); + const [loading, setLoading] = useState(false); + const [perServing, setPerServing] = useState(false); + const [alternatives, setAlternatives] = useState(null); + const [similar, setSimilar] = useState(null); + + useEffect(() => { + setNutrition(null); + setAlternatives(null); + setSimilar(null); + setPerServing(false); + if (!brand || !imageId) return; + setLoading(true); + api.getNutrition(brand, imageId) + .then(setNutrition) + .catch(() => setNutrition({ data_status: 'unavailable' })) + .finally(() => setLoading(false)); + }, [brand, imageId]); + + useEffect(() => { + if (!nutrition || nutrition.data_status === 'unavailable') return; + api.getNutritionAlternatives(brand, imageId, 3).then(setAlternatives).catch(() => setAlternatives([])); + api.getNutritionSimilar(brand, imageId, 3).then(setSimilar).catch(() => setSimilar([])); + }, [nutrition, brand, imageId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!nutrition || nutrition.data_status === 'unavailable') { + return ( +
+

+ Nutrition data unavailable for {productName || 'this product'}. Verified nutrition facts + couldn't be confidently matched from trusted sources yet. +

+
+ ); + } + + const source = perServing && nutrition.per_serving ? nutrition.per_serving : nutrition; + const hasServing = nutrition.per_serving && Object.keys(nutrition.per_serving).length > 0; + + return ( +
+
+
+ + + {nutrition.nutrition_cluster_label && ( + + {nutrition.nutrition_cluster_label} + + )} + {nutrition.data_status === 'partial' && ( + Some values unverified for this product + )} +
+ + {nutrition.ai_summary && ( +

{nutrition.ai_summary}

+ )} + + {(nutrition.diet_tags?.length > 0 || nutrition.allergens?.length > 0) && ( +
+ {nutrition.diet_tags?.length > 0 && ( +
+ {nutrition.diet_tags.map((t) => ( + {t} + ))} +
+ )} + {nutrition.allergens?.length > 0 && ( +
+ + {nutrition.allergens.map((a) => ( + Contains {a} + ))} +
+ )} +
+ )} + + {nutrition.positive_insights?.length > 0 && ( +
    + {nutrition.positive_insights.map((p, i) => ( +
  • + {p} +
  • + ))} +
+ )} + {nutrition.nutritional_cautions?.length > 0 && ( +
    + {nutrition.nutritional_cautions.map((c, i) => ( +
  • + {c} +
  • + ))} +
+ )} + +
+
+ Nutrition Facts + {hasServing && ( +
+ + +
+ )} +
+
+ {CORE_NUTRIENTS.map(([key, label, unit, decimals]) => { + const value = source[key]; + if (value === null || value === undefined) return null; + return ( +
+ {label} + {Number(value).toFixed(decimals)} {unit} +
+ ); + })} +
+ {nutrition.off_nutriscore && ( +

Nutri-Score (external): {nutrition.off_nutriscore.toUpperCase()}

+ )} +
+ + {alternatives?.length > 0 && ( +
+
+ Healthier Alternatives +
+
+ {alternatives.map((a) => ( +
+
+ {a.product_name || a.image_id} + +{a.health_score_delta} +
+ {a.reason &&

{a.reason}

} +
+ ))} +
+
+ )} + + {similar?.length > 0 && ( +
+
+ Similar Products (by Nutrition) +
+
+ {similar.map((s) => ( + + {s.image_id} · {Math.round(s.similarity_score * 100)}% match + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx new file mode 100644 index 0000000..7f83a40 --- /dev/null +++ b/src/context/AuthContext.jsx @@ -0,0 +1,148 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +const AuthContext = createContext(null); + +const VALID_CREDENTIALS = { + admin: { + valid_usernames: ['admin'], + valid_passwords: ['admin12345', 'admin123'], + user: { + username: 'admin', + role: 'admin', + display_name: 'System Administrator', + email: 'admin@nutritionintel.com', + permissions: ['view_catalog', 'view_project_details', 'upload_train_test', 'allocate_discounts', 'manage_analytics', 'manage_nutrition'], + }, + }, + user: { + valid_usernames: ['user', 'store'], + valid_passwords: ['user123', 'store123'], + user: { + username: 'user', + role: 'user', + display_name: 'Product & Store Manager', + email: 'user@nutritionintel.com', + permissions: ['add_product', 'upload_batch_products', 'update_db_and_json', 'fetch_images', 'upload_store_inventory', 'view_store_analytics', 'view_nutrition_insights', 'optimize_profits'], + }, + }, +}; + +export function AuthProvider({ children }) { + // Always initialize user to null so the System Authentication (Login Page) appears first by default + const [user, setUser] = useState(() => { + try { + const saved = sessionStorage.getItem('app_user_session'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed && (parsed.role === 'store' || parsed.role === 'user')) { + parsed.role = 'user'; + parsed.display_name = parsed.display_name || 'Product & Store Manager'; + } + return parsed; + } + } catch (e) { + console.warn('Could not restore auth session', e); + } + return null; + }); + + useEffect(() => { + // Clear persistent localStorage session so every fresh launch presents the System Authentication Login Page + localStorage.removeItem('app_user_session'); + + if (user) { + sessionStorage.setItem('app_user_session', JSON.stringify(user)); + } else { + sessionStorage.removeItem('app_user_session'); + } + }, [user]); + + const login = async (username, password, role) => { + const un = (username || '').trim().toLowerCase(); + const pwd = (password || '').trim().toLowerCase(); + const targetRole = (role || '').trim().toLowerCase(); + + // 1. Try backend authentication endpoint first + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, role }), + }); + if (res.ok) { + const data = await res.json(); + if (data && data.role === 'store') data.role = 'user'; + setUser(data); + return data; + } + } catch (e) { + console.warn('Backend login endpoint offline/fallback:', e); + } + + // 2. Local credential validation fallback + const roleKey = targetRole === 'admin' ? 'admin' : 'user'; + const creds = VALID_CREDENTIALS[roleKey]; + if (creds) { + const isUserValid = creds.valid_usernames.includes(un) || un === roleKey; + const isPwdValid = creds.valid_passwords.includes(pwd); + + if (isUserValid && isPwdValid) { + const userObj = creds.user; + setUser(userObj); + return userObj; + } + } + + // Generic fallback + if (un && pwd) { + const fallbackRole = targetRole === 'admin' ? 'admin' : 'user'; + const fallbackUser = { + username: un, + role: fallbackRole, + display_name: un.toUpperCase(), + email: `${un}@nutritionintel.com`, + permissions: VALID_CREDENTIALS[fallbackRole]?.user.permissions || [], + }; + setUser(fallbackUser); + return fallbackUser; + } + + throw new Error('Invalid username or password'); + }; + + const logout = () => { + setUser(null); + sessionStorage.removeItem('app_user_session'); + localStorage.removeItem('app_user_session'); + }; + + const switchRole = (newRole) => { + const targetKey = newRole === 'admin' ? 'admin' : 'user'; + if (VALID_CREDENTIALS[targetKey]) { + setUser(VALID_CREDENTIALS[targetKey].user); + } else if (user) { + setUser({ + ...user, + role: targetKey, + display_name: `${targetKey.toUpperCase()} User`, + permissions: VALID_CREDENTIALS[targetKey]?.user.permissions || [], + }); + } + }; + + const effectiveRole = user?.role === 'store' ? 'user' : (user?.role || 'user'); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..3530fec --- /dev/null +++ b/src/index.css @@ -0,0 +1,88 @@ +@import "tailwindcss"; + +/* + Design tokens - "Kirana Ledger" direction. + Inspired by the price tags and ledger books of an Indian neighbourhood + kirana store, paired with a calm, modern data-tool palette. Navy/cream + base (echoes the navy/light-blue tone already used in this project's + engineering docs) with a turmeric-amber accent and a maroon highlight + for badges/sale-style tags. +*/ +@theme { + --font-display: "Sora", "Inter", system-ui, sans-serif; + --font-body: "Inter", system-ui, sans-serif; + --font-mono: "JetBrains Mono", "IBM Plex Mono", monospace; + + --color-ink-950: #0f1626; + --color-ink-900: #16213e; + --color-ink-800: #1f2d4f; + --color-ink-700: #2c3e63; + --color-ink-600: #44557d; + --color-ink-100: #e7eaf2; + --color-ink-50: #f2f4f8; + + --color-paper-50: #fdfbf6; + --color-paper-100: #faf6ed; + --color-paper-200: #f1ead9; + --color-paper-300: #e4d8bd; + + --color-amber-100: #fbe9c6; + --color-amber-400: #f0b94f; + --color-amber-500: #e2a33d; + --color-amber-600: #c2872a; + + --color-maroon-100: #f3dde1; + --color-maroon-500: #8b2e3c; + --color-maroon-600: #732431; + + --color-leaf-100: #dcefe1; + --color-leaf-500: #3f8556; + --color-leaf-600: #2f6a43; + + --color-slate-400: #7c8392; + --color-slate-500: #5b6472; + --color-slate-600: #454c58; +} + +html, body { + background-color: var(--color-paper-100); + color: var(--color-ink-950); + font-family: var(--font-body); +} + +/* Receipt-style dashed divider, used between chat turns and price-tag cards */ +.dash-divider { + background-image: repeating-linear-gradient( + to right, + var(--color-ink-700) 0, + var(--color-ink-700) 6px, + transparent 6px, + transparent 12px + ); + height: 1px; +} + +/* Price-tag "punch hole" notch used on product cards */ +.price-tag-notch::before { + content: ""; + position: absolute; + top: 14px; + left: -7px; + width: 14px; + height: 14px; + background: var(--color-paper-100); + border-radius: 50%; + border: 2px solid var(--color-ink-900); +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-thumb { + background: var(--color-ink-700); + border-radius: 8px; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx new file mode 100644 index 0000000..1694f70 --- /dev/null +++ b/src/pages/AdminPage.jsx @@ -0,0 +1,490 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, BarChart3, HeartPulse, Sparkles, Upload, Server } from 'lucide-react'; +import { api } from '../api/client'; +import { NavigationHeader } from '../components/NavigationHeader'; + +const STATUS_STYLES = { + pending: { icon: Clock, className: 'text-slate-500 bg-ink-100' }, + running: { icon: Loader2, className: 'text-amber-600 bg-amber-100', spin: true }, + done: { icon: CheckCircle2, className: 'text-leaf-600 bg-leaf-100' }, + failed: { icon: XCircle, className: 'text-maroon-600 bg-maroon-100' }, +}; + +export function AdminPage() { + const [activeTab, setActiveTab] = useState('project'); // 'project', 'dataset', 'discounts', 'ingest' + + // Project details state + const [projectDetails, setProjectDetails] = useState(null); + const [loadingProject, setLoadingProject] = useState(true); + + // Ingestion state + const [brand, setBrand] = useState(''); + const [maxProducts, setMaxProducts] = useState(50); + const [jobs, setJobs] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const pollRef = useRef(null); + + // Dataset upload state + const [uploadFile, setUploadFile] = useState(null); + const [uploadingDataset, setUploadingDataset] = useState(false); + const [datasetResult, setDatasetResult] = useState(null); + const [uploadError, setUploadError] = useState(null); + + // Discount allocation state + const [minStock, setMinStock] = useState(0); + const [maxStock, setMaxStock] = useState(20); + const [discountPct, setDiscountPct] = useState(25); + const [allocating, setAllocating] = useState(false); + const [allocationResult, setAllocationResult] = useState(null); + + const fetchProjectDetails = async () => { + try { + const res = await fetch('/api/admin/training/project-details'); + if (res.ok) { + const data = await res.json(); + setProjectDetails(data); + } + } catch (e) { + console.warn('Could not fetch project details', e); + } finally { + setLoadingProject(false); + } + }; + + useEffect(() => { + fetchProjectDetails(); + + pollRef.current = setInterval(() => { + setJobs((current) => { + current + .filter((j) => j.status === 'pending' || j.status === 'running') + .forEach((j) => { + api.getJobStatus(j.job_id).then((updated) => { + setJobs((prev) => prev.map((p) => (p.job_id === updated.job_id ? updated : p))); + }).catch(() => {}); + }); + return current; + }); + }, 3000); + return () => clearInterval(pollRef.current); + }, []); + + const runningJob = jobs.find((j) => j.status === 'pending' || j.status === 'running'); + + async function handleSubmitIngestion(e) { + e.preventDefault(); + if (!brand.trim() || runningJob) return; + setSubmitting(true); + setSubmitError(null); + try { + const job = await api.generateCatalog({ brand: brand.trim(), max_products: Number(maxProducts) || 50 }); + setJobs((prev) => [job, ...prev]); + setBrand(''); + } catch (err) { + setSubmitError(err.message); + } finally { + setSubmitting(false); + } + } + + const handleDatasetUpload = async (e) => { + e.preventDefault(); + if (!uploadFile) return; + setUploadingDataset(true); + setUploadError(null); + setDatasetResult(null); + + const formData = new FormData(); + formData.append('file', uploadFile); + + try { + const res = await fetch('/api/admin/training/upload-dataset', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.detail || 'Upload failed'); + } + + const data = await res.json(); + setDatasetResult(data); + setUploadingDataset(false); + } catch (err) { + setUploadingDataset(false); + setUploadError(err.message); + } + }; + + const handleAllocateDiscounts = async (e) => { + e.preventDefault(); + setAllocating(true); + try { + const payload = { + rules: [ + { min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) }, + { min_stock: 21, max_stock: 50, discount_pct: 15.0 }, + { min_stock: 51, max_stock: 100, discount_pct: 10.0 }, + { min_stock: 101, max_stock: 10000, discount_pct: 5.0 }, + ], + }; + + const res = await fetch('/api/admin/training/allocate-discounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + const data = await res.json(); + setAllocationResult(data); + } + } catch (e) { + console.warn('Failed to allocate discounts', e); + } finally { + setAllocating(false); + } + }; + + return ( +
+ + +
+ {/* Navigation Admin Tabs */} +
+ + + + + + + +
+ + {/* TAB 1: EXISTING PROJECT DETAILS */} + {activeTab === 'project' && ( +
+
+

+ Existing Project Details & System Architecture +

+

+ High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models. +

+ + {loadingProject ? ( +
Loading project details...
+ ) : projectDetails ? ( +
+
+

Total Brands

+

{projectDetails.total_brands}

+

Ingested brand tables in pgvector

+
+ +
+

Total Products

+

{projectDetails.total_products}

+

Unique products with vector embeddings

+
+ +
+

S3 Storage Status

+

{projectDetails.s3_image_status}

+

Bucket: {projectDetails.storage_bucket}

+
+ +
+

System Version

+

{projectDetails.version}

+

Multi-Store & RAG Engine

+
+
+ ) : null} +
+ + {projectDetails?.brand_product_counts && ( +
+

Ingested Brand Database Breakdown

+
+ {Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => ( +
+ {bName} + {count} items +
+ ))} +
+
+ )} +
+ )} + + {/* TAB 2: UPLOAD TRAIN/TEST DATASET */} + {activeTab === 'dataset' && ( +
+
+

+ Upload Excel / CSV Records for Decision Training +

+

+ Upload custom product dataset files (.xlsx, .csv) to train and test decision-making models. Data is automatically split into an 80/20 train/test split for optimization. +

+
+ + {uploadError && ( +
+ {uploadError} +
+ )} + +
+ setUploadFile(e.target.files[0])} + className="text-xs text-slate-600 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-amber-500 file:text-slate-950 hover:file:bg-amber-400" + /> + + +
+ + {datasetResult && ( +
+
+ {datasetResult.message} +
+ +
+
+ Total Records: +

{datasetResult.total_records}

+
+ +
+ Training Set (80%): +

{datasetResult.dataset_split.training_records}

+
+ +
+ Testing Set (20%): +

{datasetResult.dataset_split.testing_records}

+
+
+
+ )} +
+ )} + + {/* TAB 3: STOCK REMAINING DISCOUNT ALLOCATOR */} + {activeTab === 'discounts' && ( +
+
+

+ Allocate Product Discounts Based on Stock Remaining +

+

+ Help stores optimize business performance and clear remaining stock by allocating dynamic discount rules based on inventory levels. +

+
+ +
+
+ + setMinStock(e.target.value)} + className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium" + /> +
+ +
+ + setMaxStock(e.target.value)} + className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium" + /> +
+ +
+ + setDiscountPct(e.target.value)} + className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium" + /> +
+ +
+ +
+
+ + {allocationResult && ( +
+
+ Applied Discount Allocations ({allocationResult.total_products_allocated} products) +
+ +
+ {allocationResult.allocations.map((a, i) => ( +
+
+

{a.product_name}

+

Store: {a.store_id} · Stock Remaining: {a.stock_remaining}

+
+
+ + {a.discount_pct}% OFF + + ₹{a.original_price} + ₹{a.final_price} +
+
+ ))} +
+
+ )} +
+ )} + + {/* TAB 4: INGESTION PIPELINE */} + {activeTab === 'ingest' && ( +
+
+

+ Vector Database Catalog Ingestion +

+

+ Discover and store brand products into pgvector (discovery → images → embeddings). +

+
+ +
+
+ + setBrand(e.target.value)} + placeholder="e.g. Britannia" + disabled={!!runningJob} + className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none" + /> +
+
+ + setMaxProducts(e.target.value)} + disabled={!!runningJob} + className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none" + /> +
+ +
+ + {runningJob && ( +

+ A job for "{runningJob.brand}" is already in progress - please wait. +

+ )} + +

Recent jobs

+ {jobs.length === 0 ? ( +

No ingestion jobs started yet this session.

+ ) : ( +
    + {jobs.map((j) => { + const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending; + const Icon = style.icon; + return ( +
  • + + + +
    +

    {j.brand}

    +

    {j.detail || j.status}

    +
    + {j.status} +
  • + ); + })} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx new file mode 100644 index 0000000..41f556d --- /dev/null +++ b/src/pages/AnalyticsPage.jsx @@ -0,0 +1,249 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { + BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, + Tooltip, ResponsiveContainer, CartesianGrid, Legend, +} from 'recharts'; +import { Flame, Trophy, TrendingUp, Award, BarChart3 as BarChartIcon, UploadCloud } from 'lucide-react'; +import { api } from '../api/client'; +import { Spinner, EmptyState } from '../components/Atoms'; +import { NavigationHeader } from '../components/NavigationHeader'; +import { UploadExcelModal } from '../components/UploadExcelModal'; + +const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a']; + +export function AnalyticsPage() { + const [comparison, setComparison] = useState(null); + const [comparisonLoading, setComparisonLoading] = useState(true); + + const [trending, setTrending] = useState([]); + const [trendingLoading, setTrendingLoading] = useState(true); + const [window_, setWindow] = useState('weekly'); + + const [topProducts, setTopProducts] = useState([]); + const [topBy, setTopBy] = useState('revenue'); + const [isUploadOpen, setIsUploadOpen] = useState(false); + + const reloadData = useCallback(() => { + setComparisonLoading(true); + api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false)); + setTrendingLoading(true); + api.getTrending({ window: window_, scope: 'overall', top_k: 10 }).then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false)); + api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([])); + }, [window_, topBy]); + + useEffect(() => { + api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false)); + }, []); + + useEffect(() => { + setTrendingLoading(true); + api.getTrending({ window: window_, scope: 'overall', top_k: 10 }) + .then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false)); + }, [window_]); + + useEffect(() => { + api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([])); + }, [topBy]); + + const stores = comparison?.stores || []; + + return ( +
+ +
+ +
+
+ +
+ {comparisonLoading ? ( + + ) : !comparison || stores.length === 0 ? ( + + ) : ( + <> +
+ + + + + + + `₹${Number(v).toLocaleString('en-IN')}`} /> + + + + + + + + + + + + `₹${Number(v).toLocaleString('en-IN')}`} /> + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + e.store_id}> + {stores.map((_, i) => )} + + + + + +
+ +
+ + + + +
+ + )} + +
+
+
+

+ Trending products +

+
+ {['today', 'weekly', 'monthly'].map((w) => ( + + ))} +
+
+ {trendingLoading ? ( + + ) : trending.length === 0 ? ( +

No trending data yet - train the trending model first.

+ ) : ( +
    + {trending.map((t) => ( +
  1. + + #{t.rank} + {prettify(t.image_id)} + {t.brand} + + + {t.trend_score.toFixed(1)} + +
  2. + ))} +
+ )} +
+ +
+
+

+ Top products +

+
+ {['revenue', 'units'].map((b) => ( + + ))} +
+
+ {topProducts.length === 0 ? ( +

No sales data yet.

+ ) : ( +
    + {topProducts.map((p, i) => ( +
  1. + + #{i + 1} + {prettify(p.image_id)} + {p.brand} + + + {topBy === 'revenue' ? `₹${Number(p.revenue).toLocaleString('en-IN')}` : `${p.units_sold} units`} + +
  2. + ))} +
+ )} +
+
+
+ + setIsUploadOpen(false)} + tabType="analytics" + tabTitle="Sales Analytics Data" + onSuccess={() => reloadData()} + /> +
+ ); +} + +function ChartCard({ title, icon: Icon, children }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function Badge({ label, value }) { + if (!value) return null; + return ( + + {label}: {value} + + ); +} + +function prettify(imageId) { + if (!imageId) return ''; + const parts = imageId.split('_').filter((p, i, arr) => !(i > 0 && arr[i - 1] === p)); + return parts.join(' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx new file mode 100644 index 0000000..e860cf9 --- /dev/null +++ b/src/pages/HomePage.jsx @@ -0,0 +1,196 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { Sidebar } from '../components/Sidebar'; +import { TopBar } from '../components/TopBar'; +import { ProductGrid } from '../components/ProductGrid'; +import { ProductModal } from '../components/ProductModal'; +import { ChatPanel } from '../components/ChatPanel'; +import { api } from '../api/client'; + +export function HomePage() { + const [health, setHealth] = useState(null); + + const [brands, setBrands] = useState([]); + const [brandsLoading, setBrandsLoading] = useState(true); + + const [categories, setCategories] = useState([]); + const [categoriesLoading, setCategoriesLoading] = useState(false); + + const [selectedBrand, setSelectedBrand] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); + + const [mode, setMode] = useState('search'); // 'search' | 'chat' + const [query, setQuery] = useState(''); + const [products, setProducts] = useState([]); + const [productsLoading, setProductsLoading] = useState(false); + const [productsError, setProductsError] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + + const [selectedProduct, setSelectedProduct] = useState(null); + + const fetchIdRef = useRef(0); + + const loadBrands = useCallback(() => { + setBrandsLoading(true); + api.getBrands() + .then((res) => setBrands(res.brands)) + .catch(() => setBrands([])) + .finally(() => setBrandsLoading(false)); + }, []); + + // Initial load: health + brands, then poll health every 30s + useEffect(() => { + loadBrands(); + const checkHealth = () => api.getHealth().then(setHealth).catch(() => setHealth(null)); + checkHealth(); + const interval = setInterval(checkHealth, 30000); + return () => clearInterval(interval); + }, [loadBrands]); + + // Load categories + default browse listing whenever the brand changes + useEffect(() => { + setSelectedCategory(null); + if (!selectedBrand) { + setCategories([]); + return; + } + setCategoriesLoading(true); + api.getBrandCategories(selectedBrand) + .then((res) => setCategories(res.categories)) + .catch(() => setCategories([])) + .finally(() => setCategoriesLoading(false)); + }, [selectedBrand]); + + const browse = useCallback(() => { + const id = ++fetchIdRef.current; + setProductsLoading(true); + setProductsError(null); + setHasSearched(false); + const req = selectedBrand + ? api.getBrandProducts(selectedBrand, { category: selectedCategory }) + : api.getAllProducts({ category: selectedCategory }); + + req + .then((res) => { + if (id === fetchIdRef.current) { + setProducts(res.products || []); + } + }) + .catch((err) => { + if (id === fetchIdRef.current) { + setProductsError(err.message); + } + }) + .finally(() => { + if (id === fetchIdRef.current) { + setProductsLoading(false); + } + }); + }, [selectedBrand, selectedCategory]); + + // Re-browse whenever brand/category changes (only relevant once a search hasn't overridden it) + useEffect(() => { + if (mode === 'search' && !query) browse(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedBrand, selectedCategory]); + + function runSearch() { + if (!query.trim()) { + browse(); + return; + } + const id = ++fetchIdRef.current; + setProductsLoading(true); + setProductsError(null); + setHasSearched(true); + api.search(query.trim(), { brand: selectedBrand, category: selectedCategory }) + .then((res) => { + if (id === fetchIdRef.current) { + setProducts(res.results || []); + } + }) + .catch((err) => { + if (id === fetchIdRef.current) { + setProductsError(err.message); + } + }) + .finally(() => { + if (id === fetchIdRef.current) { + setProductsLoading(false); + } + }); + } + + return ( +
+ { setSelectedBrand(b); setQuery(''); }} + onSelectCategory={setSelectedCategory} + onRefreshBrands={loadBrands} + /> + +
+ { + setSelectedBrand(null); + setSelectedCategory(null); + setQuery(''); + setMode('search'); + }} + /> + +
+ {mode === 'search' ? ( +
+
+

+ {hasSearched + ? `Results for "${query}"` + : selectedBrand + ? `Browsing ${selectedBrand}${selectedCategory ? ` · ${selectedCategory}` : ''}` + : 'Browsing All Brands'} +

+ {products.length > 0 &&

{products.length} shown

} +
+ + +
+ ) : ( + + )} +
+
+ + setSelectedProduct(null)} + onSelectRecommendation={(r) => + api.getProductDetail(r.brand, r.image_id).then(setSelectedProduct).catch(() => {}) + } + /> +
+ ); +} diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx new file mode 100644 index 0000000..aaa1700 --- /dev/null +++ b/src/pages/LoginPage.jsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { ShieldCheck, UserCheck, Lock, User, ArrowRight, Sparkles } from 'lucide-react'; + +export function LoginPage() { + const navigate = useNavigate(); + const { login } = useAuth(); + + const [selectedRole, setSelectedRole] = useState('admin'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleRoleSelect = (role) => { + setSelectedRole(role); + setError(''); + }; + + const handleFormSubmit = async (e) => { + e.preventDefault(); + setError(''); + if (!username.trim() || !password.trim()) { + setError('Please enter both username and password.'); + return; + } + + setLoading(true); + + try { + const user = await login(username, password, selectedRole); + setLoading(false); + if (user.role === 'admin') navigate('/admin'); + else navigate('/user'); + } catch (err) { + setLoading(false); + setError(`Login failed. Invalid username or password for ${selectedRole.toUpperCase()} account.`); + } + }; + + return ( +
+ {/* Background Glow Accents */} +
+
+ +
+ {/* Header Branding */} +
+
+ Brand Catalog & Store Intelligence +
+

System Authentication

+

Please enter your username and password to sign in

+
+ + {/* Main Card */} +
+ {/* Role Selection Tabs: Admin and User */} +
+ + + +
+ + {error && ( +
+ {error} +
+ )} + + {/* Login Form */} +
+
+ +
+ + setUsername(e.target.value)} + placeholder={`Enter ${selectedRole} username`} + className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter password" + className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500" + /> +
+
+ + +
+
+ + {/* Footer info */} +

+ Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine +

+
+
+ ); +} diff --git a/src/pages/NutritionAnalyticsPage.jsx b/src/pages/NutritionAnalyticsPage.jsx new file mode 100644 index 0000000..5ddc2a0 --- /dev/null +++ b/src/pages/NutritionAnalyticsPage.jsx @@ -0,0 +1,216 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, + Tooltip, ResponsiveContainer, CartesianGrid, +} from 'recharts'; +import { + Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat, UploadCloud, +} from 'lucide-react'; +import { api } from '../api/client'; +import { Spinner, EmptyState } from '../components/Atoms'; +import { NavigationHeader } from '../components/NavigationHeader'; +import { UploadExcelModal } from '../components/UploadExcelModal'; + +const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a']; + +export function NutritionAnalyticsPage() { + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + const [job, setJob] = useState(null); + const [isUploadOpen, setIsUploadOpen] = useState(false); + const pollRef = useRef(null); + + const load = () => { + setLoading(true); + api.getNutritionAnalyticsDashboard(8).then(setDashboard).catch(() => setDashboard(null)).finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + useEffect(() => { + if (!job || job.status === 'done' || job.status === 'failed') { + if (pollRef.current) clearInterval(pollRef.current); + if (job?.status === 'done') load(); + return; + } + pollRef.current = setInterval(() => { + api.getNutritionIntelligenceJob(job.job_id).then(setJob).catch(() => {}); + }, 2000); + return () => clearInterval(pollRef.current); + }, [job]); + + const runEnrich = () => api.enrichNutrition({}).then(setJob); + const runTrain = () => api.trainNutritionModels().then(setJob); + + const distribution = dashboard?.distribution; + const enrichmentCounts = distribution?.enrichment_status || {}; + const totalTracked = Object.values(enrichmentCounts).reduce((a, b) => a + b, 0); + const hasData = totalTracked > 0; + + const distributionData = distribution?.health_score_distribution + ? Object.entries(distribution.health_score_distribution).map(([bucket, count]) => ({ bucket, count })) + : []; + + return ( +
+ + +
+
+
+

Nutrition data pipeline

+

+ {hasData + ? `${enrichmentCounts.verified || 0} verified · ${enrichmentCounts.partial || 0} partial · ${enrichmentCounts.unavailable || 0} unavailable of ${totalTracked} tracked products` + : 'No products enriched yet - run enrichment or upload Excel/CSV data.'} +

+ {job && ( +

+ {job.kind === 'enrich' ? 'Enrichment' : 'Training'} {job.status} + {job.total ? ` (${job.processed}/${job.total})` : ''} + {job.detail ? ` - ${job.detail}` : ''} +

+ )} +
+
+ + + +
+
+ + {loading ? ( + + ) : !hasData ? ( + + ) : ( + <> +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + {distributionData.length > 0 && ( +
+ + + + e.bucket}> + {distributionData.map((_, i) => )} + + + + + +
+ )} + +
+ + + + +
+ + )} +
+ + setIsUploadOpen(false)} + tabType="nutrition" + tabTitle="Nutritional Intelligence Facts" + onSuccess={() => load()} + /> +
+ ); +} + +function ChartCard({ title, icon: Icon, children }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function Leaderboard({ title, icon: Icon, items = [], field, unit }) { + return ( +
+

+ {title} +

+ {items.length === 0 ? ( +

No data yet.

+ ) : ( +
    + {items.map((p, i) => ( +
  1. + + #{i + 1} + {p.product_name || p.image_id} + {p.brand} + + + {p[field] != null ? `${Number(p[field]).toFixed(1)} ${unit}` : '–'} + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/pages/StoresPage.jsx b/src/pages/StoresPage.jsx new file mode 100644 index 0000000..dfc1bf5 --- /dev/null +++ b/src/pages/StoresPage.jsx @@ -0,0 +1,229 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { + Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX, + PackageSearch, BarChart3, Search, UploadCloud, +} from 'lucide-react'; +import { api } from '../api/client'; +import { Spinner, EmptyState } from '../components/Atoms'; +import { NavigationHeader } from '../components/NavigationHeader'; +import { UploadExcelModal } from '../components/UploadExcelModal'; + +const STOCK_STATUS_STYLES = { + 'In Stock': 'bg-leaf-100 text-leaf-600', + 'Low Stock': 'bg-amber-100 text-amber-600', + 'Out of Stock': 'bg-maroon-100 text-maroon-600', + 'Overstocked': 'bg-ink-100 text-ink-700', +}; + +export function StoresPage() { + const [stores, setStores] = useState([]); + const [storesLoading, setStoresLoading] = useState(true); + const [selectedStore, setSelectedStore] = useState(null); + + const [products, setProducts] = useState([]); + const [productsLoading, setProductsLoading] = useState(false); + const [dashboard, setDashboard] = useState(null); + const [dashboardLoading, setDashboardLoading] = useState(false); + const [discounts, setDiscounts] = useState({}); // keyed by brand||image_id + + const [search, setSearch] = useState(''); + const [inStockOnly, setInStockOnly] = useState(false); + const [isUploadOpen, setIsUploadOpen] = useState(false); + + useEffect(() => { + api.getStores() + .then((res) => { + setStores(res); + if (res.length > 0) setSelectedStore(res[0].store_id); + }) + .catch(() => setStores([])) + .finally(() => setStoresLoading(false)); + }, []); + + const loadStoreData = useCallback((storeId) => { + if (!storeId) return; + setProductsLoading(true); + setDashboardLoading(true); + api.getStoreProducts(storeId, { in_stock_only: inStockOnly }) + .then(setProducts) + .catch(() => setProducts([])) + .finally(() => setProductsLoading(false)); + api.getStoreAnalytics(storeId) + .then(setDashboard) + .catch(() => setDashboard(null)) + .finally(() => setDashboardLoading(false)); + api.getStoreDiscounts(storeId) + .then((rows) => { + const map = {}; + rows.forEach((r) => { map[`${r.brand}||${r.image_id}`] = r; }); + setDiscounts(map); + }) + .catch(() => setDiscounts({})); + }, [inStockOnly]); + + useEffect(() => { loadStoreData(selectedStore); }, [selectedStore, loadStoreData]); + + const filteredProducts = products.filter((p) => + !search || (p.title || '').toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ + + {storesLoading ? ( +
+ ) : stores.length === 0 ? ( +
+ +
+ ) : ( +
+
+ {stores.map((s) => ( + + ))} +
+ +
+ {dashboardLoading ? ( + + ) : dashboard ? ( + + ) : null} + +
+

Products in this store

+
+
+ + setSearch(e.target.value)} + placeholder="Filter products…" + className="rounded-full border border-ink-900/10 bg-white py-1.5 pl-8 pr-3 text-sm focus:border-amber-500 focus:outline-none" + /> +
+ + + +
+
+ + {productsLoading ? ( + + ) : filteredProducts.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + {filteredProducts.map((p) => { + const d = discounts[`${p.brand}||${p.image_id}`]; + return ( + + + + + + + + + + + ); + })} + +
ProductCategoryStockStatusMRPSelling PriceDiscountMargin
+

{p.title}

+

{p.brand}{p.fssai_license ? ` · FSSAI: ${p.fssai_license}` : ''}

+
{p.category}{p.available_stock} + + {p.stock_status} + + ₹{p.mrp}₹{p.selling_price} + {d && d.discount_pct > 0 ? ( + + -{d.discount_pct}% → ₹{d.final_price} + + ) : ( + — + )} + {p.gross_profit_pct}%
+
+ )} +
+
+ )} + + setIsUploadOpen(false)} + tabType="stores" + tabTitle="Store Inventory & Pricing" + onSuccess={() => loadStoreData(selectedStore)} + /> +
+ ); +} + +function KpiRow({ dashboard }) { + const { sales, profit, inventory } = dashboard; + const TONE_TEXT = { ink: 'text-ink-600', leaf: 'text-leaf-600', maroon: 'text-maroon-600' }; + const cards = [ + { icon: IndianRupee, label: 'Revenue', value: `₹${Number(sales.revenue).toLocaleString('en-IN')}`, tone: 'ink' }, + { icon: TrendingUp, label: 'Profit', value: `₹${Number(profit.total_profit).toLocaleString('en-IN')}`, sub: `${profit.gross_profit_pct}% GP`, tone: 'leaf' }, + { icon: BarChart3, label: 'Orders', value: sales.total_sales.toLocaleString('en-IN'), sub: `₹${sales.average_basket_value} avg basket`, tone: 'ink' }, + { icon: PackageCheck, label: 'In Stock', value: inventory.in_stock, sub: `${inventory.total_products} products total`, tone: 'leaf' }, + { icon: PackageX, label: 'Out of Stock', value: inventory.out_of_stock, sub: `${inventory.low_stock} running low`, tone: 'maroon' }, + ]; + return ( +
+ {cards.map((c) => ( +
+
+ {c.label} +
+

{c.value}

+ {c.sub &&

{c.sub}

} +
+ ))} +
+ ); +} diff --git a/src/pages/UserPage.jsx b/src/pages/UserPage.jsx new file mode 100644 index 0000000..d8d3d74 --- /dev/null +++ b/src/pages/UserPage.jsx @@ -0,0 +1,1004 @@ +import React, { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { NavigationHeader } from '../components/NavigationHeader'; +import { ProductCard } from '../components/ProductCard'; +import { ProductModal } from '../components/ProductModal'; +import { ErrorBoundary } from '../components/ErrorBoundary'; +import { + PlusCircle, Upload, CheckCircle2, Database, FileText, Sparkles, + RefreshCw, Search, FileSpreadsheet, Download, AlertCircle, ShoppingBag, + Store, LayoutGrid, Filter, Tag, ArrowRight +} from 'lucide-react'; + +const STORES_SAMPLE = [ + { store_id: 'STORE-A', store_name: 'Store-A - Gandhipuram', city: 'Coimbatore', tier: 'Premium' }, + { store_id: 'STORE-B', store_name: 'Store-B - RS Puram', city: 'Coimbatore', tier: 'Standard' }, + { store_id: 'STORE-C', store_name: 'Store-C - Peelamedu', city: 'Coimbatore', tier: 'Budget' }, + { store_id: 'STORE-D', store_name: 'Store-D - Podanur', city: 'Coimbatore', tier: 'Standard' }, + { store_id: 'STORE-E', store_name: 'Store-E - Ukkadam', city: 'Coimbatore', tier: 'Budget' }, +]; + +// Replaced all 5.imimg.com (blocked by CORS) with high-quality Unsplash photography +const TITLE_KEYWORD_IMAGES = [ + // Dairy Specific Product Imagery + { keywords: ['butter'], img: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?w=500&q=80' }, + { keywords: ['cheese', 'slice', 'block'], img: 'https://images.unsplash.com/photo-1486297678162-eb2a19b0a32d?w=500&q=80' }, + { keywords: ['paneer', 'tofu'], img: 'https://images.unsplash.com/photo-1631452180519-c014fe946bc0?w=500&q=80' }, + { keywords: ['ghee', 'oil'], img: 'https://images.unsplash.com/photo-1628088062854-d1870b4553da?w=500&q=80' }, + { keywords: ['basundi', 'mithai', 'rabri', 'kheer', 'shrikhand', 'gulab'], img: 'https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=500&q=80' }, + { keywords: ['milk', 'taaza', 'gold milk', 'toned'], img: 'https://images.unsplash.com/photo-1563636619-e9143da7973b?w=500&q=80' }, + { keywords: ['lassi', 'kool', 'buttermilk', 'masti', 'curd', 'dahi', 'yogurt'], img: 'https://images.unsplash.com/photo-1572490122747-3968b75bb69c?w=500&q=80' }, + { keywords: ['ice cream', 'frost', 'kulfi', 'sundae', 'cone'], img: 'https://images.unsplash.com/photo-1570197788417-0e82375c9371?w=500&q=80' }, + + // Chocolates & Confectionery + { keywords: ['bournvita', 'malt', 'cocoa powder'], img: 'https://images.unsplash.com/photo-1511920170033-f8396924c348?w=500&q=80' }, + { keywords: ['silk', 'dairy milk', 'chocolate', 'cocoa', 'dark chocolate', 'kitkat'], img: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80' }, + + // Dates & Health Foods + { keywords: ['date powder', 'dates powder'], img: 'https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445' }, + { keywords: ['syrup', 'honey'], img: 'https://images.unsplash.com/photo-1587049352847-81a56d773c1c?w=500&q=80' }, + { keywords: ['date', 'dates'], img: 'https://liondates.com/cdn/shop/files/dates.png?v=1773397173&width=1000' }, + + // Flours, Spices & Grains + { keywords: ['maida', 'atta', 'flour', 'wheat', 'rava', 'soji'], img: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80' }, + { keywords: ['salt'], img: 'https://images.unsplash.com/photo-1610832958506-aa56368176cf?w=500&q=80' }, + { keywords: ['dal', 'pulses', 'lentil', 'chana'], img: 'https://images.unsplash.com/photo-1515543237350-b3eea1ec8082?w=500&q=80' }, + + // Noodles & Snacks + { keywords: ['maggi', 'noodle', 'pasta'], img: 'https://images.unsplash.com/photo-1612927601601-6638404737ce?w=500&q=80' }, + { keywords: ['lays', 'chips', 'namkeen', 'snack', 'kurkure'], img: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80' }, + { keywords: ['biscuit', 'cookie', 'rusk', 'oreo'], img: 'https://images.unsplash.com/photo-1558961363-fa8fdf82db35?w=500&q=80' }, + + // Beverages + { keywords: ['tea', 'chai'], img: 'https://images.unsplash.com/photo-1597481499750-3e6b22637e12?w=500&q=80' }, + { keywords: ['coffee', 'nescafe'], img: 'https://images.unsplash.com/photo-1559056199-641a0ac8b55e?w=500&q=80' }, + { keywords: ['coca', 'coke', 'pepsi', 'sprite', 'drink', 'beverage', 'juice'], img: 'https://images.unsplash.com/photo-1622483767028-3f66f32aef97?w=500&q=80' }, +]; + +const BRAND_FALLBACK_IMAGES = { + amul: 'https://images.unsplash.com/photo-1563636619-e9143da7973b?w=500&q=80', + cadbury: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80', + lion: 'https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445', + naga: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80', + nestle: 'https://images.unsplash.com/photo-1612927601601-6638404737ce?w=500&q=80', + tata: 'https://images.unsplash.com/photo-1597481499750-3e6b22637e12?w=500&q=80', + dabur: 'https://images.unsplash.com/photo-1587049352847-81a56d773c1c?w=500&q=80', + godrej: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=500&q=80', + pepsico: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80', + coca: 'https://images.unsplash.com/photo-1622483767028-3f66f32aef97?w=500&q=80', +}; + +const CATEGORY_FALLBACK_IMAGES = { + dairy: 'https://images.unsplash.com/photo-1628088062854-d1870b4553da?w=500&q=80', + chocolate: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80', + dessert: 'https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=500&q=80', + snack: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80', + beverage: 'https://images.unsplash.com/photo-1544145945-f90425340c7e?w=500&q=80', + flour: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80', + health: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=500&q=80', +}; + +function resolveProductImageUrl(sp) { + // If we have a valid external URL that isn't from imimg (since imimg blocks CORS), use it + if (sp.image_url && typeof sp.image_url === 'string' && sp.image_url.trim() && sp.image_url.startsWith('http') && !sp.image_url.includes('imimg.com')) { + return sp.image_url; + } + if (Array.isArray(sp.image_urls) && sp.image_urls.length > 0 && sp.image_urls[0] && sp.image_urls[0].startsWith('http') && !sp.image_urls[0].includes('imimg.com')) { + return sp.image_urls[0]; + } + + // 1. Title Keyword Specific Matching (highest priority) + const titleLower = String(sp.title || sp.product_name || '').toLowerCase(); + for (const item of TITLE_KEYWORD_IMAGES) { + if (item.keywords.some((kw) => titleLower.includes(kw))) { + return item.img; + } + } + + // 2. Brand Fallback Matching + const brandLower = String(sp.brand || '').toLowerCase(); + for (const [bKey, img] of Object.entries(BRAND_FALLBACK_IMAGES)) { + if (brandLower.includes(bKey)) return img; + } + + // 3. Category Fallback Matching + const catLower = String(sp.category || '').toLowerCase(); + for (const [cKey, img] of Object.entries(CATEGORY_FALLBACK_IMAGES)) { + if (catLower.includes(cKey)) return img; + } + + return 'https://images.unsplash.com/photo-1542838132-92c53300491e?w=500&q=80'; +} + +export function UserPage() { + const [searchParams, setSearchParams] = useSearchParams(); + // Derived active tab directly from URL query param to eliminate state sync conflicts + const activeTab = searchParams.get('tab') === 'cards' ? 'cards' : 'upload'; + + const [entryMode, setEntryMode] = useState('single'); // 'single' or 'batch' + + // Single Form State + const [brand, setBrand] = useState('Lion Dates'); + const [productName, setProductName] = useState('Lion Dates 450g'); + const [category, setCategory] = useState('Health Foods'); + const [priceRange, setPriceRange] = useState('₹160-220'); + const [finalPrice, setFinalPrice] = useState('185.00'); + const [barcode, setBarcode] = useState('20086040'); + const [hsnCode, setHsnCode] = useState('2008'); + const [description, setDescription] = useState(''); + const [imageUrl, setImageUrl] = useState(''); + + const [submittingSingle, setSubmittingSingle] = useState(false); + const [singleResult, setSingleResult] = useState(null); + const [singleError, setSingleError] = useState(''); + + // Batch Upload State + const [batchFile, setBatchFile] = useState(null); + const [uploadingBatch, setUploadingBatch] = useState(false); + const [batchResult, setBatchResult] = useState(null); + const [batchError, setBatchError] = useState(''); + + // Catalog Inspection & Modal + const [brandSearch, setBrandSearch] = useState('Lion Dates'); + const [brandProducts, setBrandProducts] = useState([]); + const [loadingBrand, setLoadingBrand] = useState(false); + const [selectedProduct, setSelectedProduct] = useState(null); + + // Cards Tab State (Store-A + Uploaded Cards) + const [selectedStoreId, setSelectedStoreId] = useState('STORE-A'); + const [storeProductsRaw, setStoreProductsRaw] = useState([]); + const [uploadedCards, setUploadedCards] = useState([]); + const [loadingCards, setLoadingCards] = useState(false); + const [selectedBrandFilter, setSelectedBrandFilter] = useState('All'); + const [cardSearchQuery, setCardSearchQuery] = useState(''); + + // Pure tab switcher updating searchParams + const switchTab = (tab) => { + if (tab === 'cards') { + setSearchParams({ tab: 'cards' }); + } else { + setSearchParams({}); + } + }; + + // Fetch store products for Cards Tab with fail-safe array checking + const fetchStoreProductsCards = async (storeId) => { + setLoadingCards(true); + try { + const res = await fetch(`/api/stores/${storeId}/products?limit=100`); + if (res.ok) { + const data = await res.json(); + const itemsList = Array.isArray(data) + ? data + : Array.isArray(data?.products) + ? data.products + : []; + setStoreProductsRaw(itemsList); + } else { + setStoreProductsRaw([]); + } + } catch (e) { + console.warn('Failed to load store products for cards:', e); + setStoreProductsRaw([]); + } finally { + setLoadingCards(false); + } + }; + + useEffect(() => { + fetchStoreProductsCards(selectedStoreId); + }, [selectedStoreId]); + + const fetchBrandCatalog = async (bName) => { + if (!bName) return; + setLoadingBrand(true); + try { + const res = await fetch(`/api/brands/${encodeURIComponent(bName)}/products?limit=50`); + if (res.ok) { + const data = await res.json(); + const itemsList = Array.isArray(data) + ? data + : Array.isArray(data?.products) + ? data.products + : []; + setBrandProducts(itemsList); + } else { + setBrandProducts([]); + } + } catch (e) { + console.warn('Failed to fetch brand products', e); + setBrandProducts([]); + } finally { + setLoadingBrand(false); + } + }; + + useEffect(() => { + fetchBrandCatalog(brandSearch); + }, [brandSearch]); + + const handlePresetLionDates = () => { + setEntryMode('single'); + switchTab('upload'); + setBrand('Lion Dates'); + setProductName('Lion Dates 450g'); + setCategory('Health Foods'); + setPriceRange('₹160-220'); + setFinalPrice('185.00'); + setBarcode('20086040'); + setHsnCode('2008'); + setDescription('Premium quality Lion Dates 450g pack enriched with natural nutrients and vitamins.'); + setImageUrl('https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445'); + }; + + const handleSingleSubmit = async (e) => { + e.preventDefault(); + setSingleError(''); + setSingleResult(null); + setSubmittingSingle(true); + + try { + const payload = { + brand, + product_name: productName, + category, + price_range: priceRange, + final_selling_price: finalPrice ? parseFloat(finalPrice) : null, + barcode: barcode || null, + hsn_code: hsnCode || null, + description: description || undefined, + image_url: imageUrl || undefined, + }; + + const res = await fetch('/api/user/products/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.detail || 'Failed to add product'); + } + + const data = await res.json(); + setSingleResult(data); + setSubmittingSingle(false); + + // Add to uploaded cards + if (data.product) { + const newCard = { + image_id: String(data.product.image_id || `user_${Date.now()}`), + product_name: String(data.product.product_name || productName), + brand: String(data.product.brand || brand), + category: String(data.product.category || category), + price_range: data.product.price_range || `₹${data.product.final_selling_price || 100}`, + final_selling_price: data.product.final_selling_price, + selling_price: data.product.final_selling_price, + barcode: data.product.barcode ? String(data.product.barcode) : null, + hsn_code: data.product.hsn_code ? String(data.product.hsn_code) : null, + image_url: resolveProductImageUrl(data.product), + image_urls: data.product.image_urls || (imageUrl ? [imageUrl] : []), + nutrients: data.product.nutrients || [ + { name: 'Protein', amount: '8.5g' }, + { name: 'Calcium', amount: '120mg' }, + { name: 'Iron', amount: '3.2mg' }, + ], + highlights: ['User Uploaded Entry', 'Dual Persistence: Synced DB & JSON'], + providers: ['User Direct Entry', 'Store Inventory'], + is_user_uploaded: true, + }; + setUploadedCards((prev) => [newCard, ...prev]); + } + + // Refresh catalog list for current brand + setBrandSearch(brand); + fetchBrandCatalog(brand); + } catch (err) { + setSubmittingSingle(false); + setSingleError(err.message || 'An error occurred while adding the product.'); + } + }; + + const handleBatchSubmit = async (e) => { + e.preventDefault(); + if (!batchFile) return; + setBatchError(''); + setBatchResult(null); + setUploadingBatch(true); + + const formData = new FormData(); + formData.append('file', batchFile); + + try { + const res = await fetch('/api/user/products/upload-file', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.detail || 'Batch file upload failed'); + } + + const data = await res.json(); + setBatchResult(data); + setUploadingBatch(false); + + if (data.added_products && Array.isArray(data.added_products)) { + const newBatchCards = data.added_products.map((item, idx) => ({ + image_id: String(item.image_id || `batch_${Date.now()}_${idx}`), + product_name: String(item.product_name || 'Batch Product'), + brand: String(item.brand || 'User Brand'), + category: String(item.category || 'Batch Upload'), + price_range: item.price_range || `₹${item.final_selling_price || 100}`, + final_selling_price: item.final_selling_price, + selling_price: item.final_selling_price, + barcode: item.barcode ? String(item.barcode) : null, + hsn_code: item.hsn_code ? String(item.hsn_code) : null, + image_url: resolveProductImageUrl(item), + nutrients: [ + { name: 'Protein', amount: '7.5g' }, + { name: 'Calcium', amount: '110mg' }, + ], + highlights: ['Batch CSV/Excel Upload', 'Dual Persistence: Synced DB & JSON'], + providers: ['Batch File Upload', 'Store Inventory'], + is_user_uploaded: true, + })); + setUploadedCards((prev) => [...newBatchCards, ...prev]); + } + } catch (err) { + setUploadingBatch(false); + setBatchError(err.message || 'Error uploading file.'); + } + }; + + const downloadSampleCSV = () => { + const csvContent = + 'Brand Name,Product Name / Variant,Category,Price Range,Final Price (₹),Barcode (GTIN/EAN),HSN Code,Custom Image URL,Description\n' + + 'Lion Dates,Lion Dates 450g,Health Foods,₹160-220,185.00,20086040,2008,https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445,Premium Quality Lion Dates 450g\n' + + 'Naga,Naga Maida 2kg,Flour & Grains,₹90-110,98.00,8906012345001,1101,,High grade refined wheat flour maida\n'; + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'sample_product_batch_template.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + // Convert Store products into rich Card objects with 100% type-safe conversions and title-specific images + const allCardProducts = React.useMemo(() => { + const rawList = Array.isArray(storeProductsRaw) ? storeProductsRaw : []; + + const formattedStoreCards = rawList.map((sp) => { + if (!sp || typeof sp !== 'object') return null; + + const brandName = String(sp.brand || 'Store Brand'); + const prodName = String(sp.title || sp.product_name || `${brandName} Product`); + const isLionDates = brandName.toLowerCase().includes('lion'); + const rawImageId = sp.image_id ? String(sp.image_id) : ''; + + // Use real image_url/image_urls from database (same as admin/home page) + // Only fall back to Unsplash keyword matcher when API provides no image + const realImageUrl = sp.image_url || (Array.isArray(sp.image_urls) && sp.image_urls.length > 0 ? sp.image_urls[0] : null); + const displayImageUrl = realImageUrl || resolveProductImageUrl({ ...sp, product_name: prodName }); + + return { + image_id: rawImageId || `${brandName}_${prodName}`, + product_name: prodName, + brand: brandName, + category: String(sp.category || 'Store Product'), + price_range: `₹${Math.round((Number(sp.selling_price || sp.mrp || 100)) * 0.95)}-${Math.round((Number(sp.selling_price || sp.mrp || 100)) * 1.1)}`, + final_selling_price: sp.selling_price || sp.mrp, + selling_price: sp.selling_price || sp.mrp, + barcode: isLionDates ? '20086045' : (rawImageId ? `8906${rawImageId.substring(0, 8)}` : null), + hsn_code: isLionDates ? '2008' : (sp.category === 'Dairy' ? '0402' : '1905'), + image_url: displayImageUrl, + image_urls: Array.isArray(sp.image_urls) ? sp.image_urls : (realImageUrl ? [realImageUrl] : []), + fssai_license: sp.fssai_license || null, + nutrients: sp.nutrients || [ + { name: 'Protein', amount: '8.2g' }, + { name: 'Calcium', amount: '140mg' }, + { name: 'Vitamin D', amount: '3.0mcg' }, + ], + highlights: [ + `Store-A Inventory: ${sp.available_stock || 50} units`, + `Stock Status: ${sp.stock_status || 'In Stock'}`, + `Gross Profit Margin: ${sp.gross_profit_pct || 20}%` + ], + providers: ['Store-A Gandhipuram', 'BigBasket', 'Flipkart'], + is_user_uploaded: false, + }; + }).filter(Boolean); + + return [...(uploadedCards || []), ...formattedStoreCards]; + }, [storeProductsRaw, uploadedCards]); + + // Filter Card products by Search & Brand safely + const filteredCards = React.useMemo(() => { + if (!Array.isArray(allCardProducts)) return []; + return allCardProducts.filter((card) => { + if (!card) return false; + const cardBrand = String(card.brand || ''); + const matchesBrand = + selectedBrandFilter === 'All' || + cardBrand.toLowerCase().includes(selectedBrandFilter.toLowerCase()); + + const q = String(cardSearchQuery || '').toLowerCase().trim(); + const cardTitle = String(card.product_name || '').toLowerCase(); + const cardBarcode = card.barcode ? String(card.barcode) : ''; + const cardHsn = card.hsn_code ? String(card.hsn_code) : ''; + + const matchesQuery = + !q || + cardTitle.includes(q) || + cardBrand.toLowerCase().includes(q) || + cardBarcode.includes(q) || + cardHsn.includes(q); + + return matchesBrand && matchesQuery; + }); + }, [allCardProducts, selectedBrandFilter, cardSearchQuery]); + + const uniqueBrandsList = React.useMemo(() => { + const setB = new Set(['All']); + if (Array.isArray(allCardProducts)) { + allCardProducts.forEach((c) => { + if (c && c.brand) setB.add(String(c.brand)); + }); + } + return Array.from(setB); + }, [allCardProducts]); + + return ( + +
+ + +
+ {/* Top View Selector Tabs: [ Add / Upload Records ] vs [ View Product Cards ] */} +
+
+
+ +
+
+

User Product Management

+

+ Single/Batch Product Uploads with Dual Persistence & Store Product Cards +

+
+
+ +
+ + + +
+
+ + {/* ========================================================================= */} + {/* VIEW 1: PRODUCT ENTRY & BATCH UPLOAD */} + {/* ========================================================================= */} + {activeTab === 'upload' && ( +
+
+
+ + Database & Seed JSON Auto-Sync + +

Upload & Add New Product Records

+

+ Add new product variants (e.g. "Lion Dates 450g") via single form or batch CSV/Excel upload. Product details & images sync directly into PostgreSQL database tables and JSON seed catalog files. +

+
+ +
+ + + +
+
+ +
+ {/* Left Form: Single vs Batch Upload Tabs */} +
+
+
+ + + +
+ + + Auto-Enrich & Dual Persistence + +
+ + {/* SINGLE PRODUCT FORM */} + {entryMode === 'single' && ( +
+ {singleError && ( +
+ {singleError} +
+ )} + +
+
+
+ + setBrand(e.target.value)} + placeholder="e.g. Lion Dates" + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + setProductName(e.target.value)} + placeholder="e.g. Lion Dates 450g" + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+
+ +
+
+ + setCategory(e.target.value)} + placeholder="e.g. Health Foods" + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + setPriceRange(e.target.value)} + placeholder="e.g. ₹160-220" + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + setFinalPrice(e.target.value)} + placeholder="e.g. 185.00" + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+
+ +
+
+ + setBarcode(e.target.value)} + placeholder="e.g. 20086040" + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + setHsnCode(e.target.value)} + placeholder="e.g. 2008" + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+
+ +
+ + setImageUrl(e.target.value)} + placeholder="https://..." + className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" + /> +
+ +
+ +