Initial commit
This commit is contained in:
6
frontend/.env.example
Normal file
6
frontend/.env.example
Normal file
@@ -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
|
||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@@ -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?
|
||||
8
frontend/.oxlintrc.json
Normal file
8
frontend/.oxlintrc.json
Normal file
@@ -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 }]
|
||||
}
|
||||
}
|
||||
55
frontend/README.md
Normal file
55
frontend/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
|
||||
```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`).
|
||||
|
||||
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)
|
||||
```
|
||||
22
frontend/index.html
Normal file
22
frontend/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet" />
|
||||
<title>Brand Catalog - Product LLM</title>
|
||||
<meta name="description" content="Local, CPU-only AI search and RAG chat over an Indian FMCG product catalog." />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
2176
frontend/package-lock.json
generated
Normal file
2176
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
20
frontend/src/App.jsx
Normal file
20
frontend/src/App.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { StoresPage } from './pages/StoresPage';
|
||||
import { AnalyticsPage } from './pages/AnalyticsPage';
|
||||
import { NutritionAnalyticsPage } from './pages/NutritionAnalyticsPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/nutrition-analytics" element={<NutritionAnalyticsPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
162
frontend/src/api/client.js
Normal file
162
frontend/src/api/client.js
Normal file
@@ -0,0 +1,162 @@
|
||||
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'),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
19
frontend/src/components/Atoms.jsx
Normal file
19
frontend/src/components/Atoms.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function Spinner({ label = 'Loading…', className = '' }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-sm text-slate-500 ${className}`}>
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-ink-700/25 border-t-amber-500" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, subtitle, action }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-ink-700/20 bg-paper-50 px-6 py-14 text-center">
|
||||
{Icon && <Icon className="mb-1 h-9 w-9 text-ink-700/40" strokeWidth={1.5} />}
|
||||
<p className="font-display text-base font-semibold text-ink-900">{title}</p>
|
||||
{subtitle && <p className="max-w-sm text-sm text-slate-500">{subtitle}</p>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/ChatMessage.jsx
Normal file
57
frontend/src/components/ChatMessage.jsx
Normal file
@@ -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 (
|
||||
<div className="flex justify-end">
|
||||
<div className="flex max-w-[75%] items-start gap-2">
|
||||
<p className="rounded-2xl rounded-tr-sm bg-ink-900 px-4 py-2.5 text-sm text-paper-100">{content}</p>
|
||||
<span className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ink-100 text-ink-700">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex max-w-[85%] items-start gap-2">
|
||||
<span className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-amber-500 text-ink-950">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="rounded-2xl rounded-tl-sm border border-ink-900/10 bg-paper-50 px-4 py-3 text-sm text-ink-950 shadow-sm">
|
||||
{detectedCategory && (
|
||||
<div className="mb-2 inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2.5 py-1 text-[11px] font-semibold text-amber-700">
|
||||
<Tag className="h-3 w-3" /> Searched in: {detectedCategory}
|
||||
</div>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{content}</p>
|
||||
|
||||
{sources.length > 0 && (
|
||||
<>
|
||||
<div className="dash-divider my-3 opacity-30" />
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Receipt className="h-3 w-3" /> Sourced from {sources.length} catalog item{sources.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.image_id || s.product_name || s.title}
|
||||
onClick={() => onSelectSource?.(s)}
|
||||
className="rounded-full border border-ink-900/15 bg-paper-100 px-2.5 py-1 font-mono text-[11px] text-ink-700 transition hover:border-amber-500 hover:text-amber-600"
|
||||
title={s.price_range || ''}
|
||||
>
|
||||
{s.product_name || s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend/src/components/ChatPanel.jsx
Normal file
112
frontend/src/components/ChatPanel.jsx
Normal file
@@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 text-amber-600">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display font-semibold text-ink-950">Ask about the catalog</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-slate-500">
|
||||
I'll answer using only the products actually in the catalog
|
||||
{brand ? ` for ${brand}` : ''}, and show you exactly which ones I used.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => send(s)}
|
||||
className="rounded-full border border-ink-900/15 px-3 py-1.5 text-xs text-ink-700 transition hover:border-amber-500 hover:text-amber-600"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m, i) => (
|
||||
<ChatMessage key={i} role={m.role} content={m.content} sources={m.sources} detectedCategory={m.detectedCategory} onSelectSource={onSelectSource} />
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 pl-9">
|
||||
<Spinner label="Thinking through the catalog…" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-maroon-100 px-3 py-2 text-sm text-maroon-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}}
|
||||
className="flex items-center gap-2 border-t border-ink-900/10 bg-paper-50 px-4 py-3"
|
||||
>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !input.trim()}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ink-900 text-paper-100 transition hover:bg-ink-800 disabled:opacity-40"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/components/ProductCard.jsx
Normal file
120
frontend/src/components/ProductCard.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode } 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) return false;
|
||||
const cat = category.toLowerCase();
|
||||
return FOOD_CATEGORIES.some((kw) => cat.includes(kw));
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onClick, similarity }) {
|
||||
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, nutrients = [], fssai_license, product_sku, sku_source } = product;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const showNutrients = useMemo(() => isFoodCategory(category) && nutrients.length > 0, [category, nutrients]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="price-tag-notch group relative flex h-full flex-col gap-2.5 rounded-xl border border-ink-900/10 bg-paper-50 p-4 pl-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-500/60 hover:shadow-md"
|
||||
>
|
||||
{image_url && !imgError ? (
|
||||
<div className="-mx-4 -mt-4 -ml-6 mb-1 overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
src={image_url}
|
||||
alt={product_name}
|
||||
className="h-40 w-full object-contain bg-ink-50"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</div>
|
||||
) : image_url && imgError ? (
|
||||
<div className="-mx-4 -mt-4 -ml-6 mb-1 flex h-40 items-center justify-center rounded-t-xl bg-ink-50">
|
||||
<ImageOff className="h-8 w-8 text-slate-300" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-display text-[15px] font-semibold leading-snug text-ink-950">{product_name}</p>
|
||||
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{brand} {category ? `· ${category}` : ''}
|
||||
</p>
|
||||
{product_sku && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<Barcode className="h-3 w-3" />
|
||||
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{typeof similarity === 'number' && (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-mono font-medium text-amber-600">
|
||||
<Sparkles className="h-3 w-3" /> {Math.round(similarity * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{price_range && (
|
||||
<p className="font-mono text-lg font-semibold text-leaf-600">{price_range}</p>
|
||||
)}
|
||||
|
||||
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).slice(0, 3).map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-ink-100 px-2 py-0.5 text-[11px] font-medium text-ink-700"
|
||||
>
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNutrients && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Leaf className="h-3 w-3 shrink-0 text-leaf-700" />
|
||||
{nutrients.map((n, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-leaf-100 px-2 py-0.5 text-[11px] font-medium text-leaf-700"
|
||||
>
|
||||
{n.split(' - ')[0]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dash-divider mt-auto" />
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Store className="h-3.5 w-3.5" />
|
||||
{providers.length > 0 ? providers.join(', ') : 'No listed providers'}
|
||||
</div>
|
||||
|
||||
{fssai_license && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-leaf-700">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
<span>FSSAI: {fssai_license}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/ProductGrid.jsx
Normal file
46
frontend/src/components/ProductGrid.jsx
Normal file
@@ -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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner label="Fetching products…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={PackageSearch}
|
||||
title="Couldn't load products"
|
||||
subtitle={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={PackageSearch}
|
||||
title={emptyTitle || 'No products found'}
|
||||
subtitle={emptySubtitle || 'Try a different search term, brand, or category.'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{products.map((p) => (
|
||||
<ProductCard
|
||||
key={p.image_id || `${p.brand}-${p.product_name}`}
|
||||
product={p}
|
||||
similarity={showSimilarity ? p.similarity : undefined}
|
||||
onClick={() => onSelect?.(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
frontend/src/components/ProductModal.jsx
Normal file
153
frontend/src/components/ProductModal.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { NutritionPanel } from './nutrition/NutritionPanel';
|
||||
|
||||
export function ProductModal({ product, onClose, onSelectRecommendation }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [recommendations, setRecommendations] = useState(null);
|
||||
const [recsLoading, setRecsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
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]);
|
||||
|
||||
if (!product) return null;
|
||||
const {
|
||||
product_name, brand, category, description, price_range,
|
||||
size_variants = [], providers = [], highlights = [], nutrients = [],
|
||||
image_url, product_sku, sku_source,
|
||||
} = product;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/50 p-4" onClick={onClose}>
|
||||
<div className="relative max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-paper-50 p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-4 top-4 rounded-full p-1.5 text-slate-500 hover:bg-ink-100 hover:text-ink-900">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{image_url && !imgError ? (
|
||||
<div className="-mx-6 -mt-6 mb-4 overflow-hidden rounded-t-2xl">
|
||||
<img
|
||||
src={image_url}
|
||||
alt={product_name}
|
||||
className="h-56 w-full object-contain bg-ink-50"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</div>
|
||||
) : image_url && imgError ? (
|
||||
<div className="-mx-6 -mt-6 mb-4 flex h-56 items-center justify-center rounded-t-2xl bg-ink-50">
|
||||
<ImageOff className="h-10 w-10 text-slate-300" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-amber-600">
|
||||
{brand} {category ? `· ${category}` : ''}
|
||||
</p>
|
||||
<h2 className="mt-1 font-display text-xl font-bold text-ink-950">{product_name}</h2>
|
||||
{product_sku && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<Barcode className="h-3.5 w-3.5" />
|
||||
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{price_range && (
|
||||
<p className="mt-3 font-mono text-2xl font-semibold text-leaf-600">{price_range}</p>
|
||||
)}
|
||||
|
||||
{description && <p className="mt-3 text-sm leading-relaxed text-ink-800">{description}</p>}
|
||||
|
||||
<NutritionPanel brand={brand} imageId={product.image_id} productName={product_name} />
|
||||
|
||||
{size_variants.length > 0 && (
|
||||
<Section icon={Tag} label="Available sizes">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{size_variants.map((s, i) => (
|
||||
<span key={i} className="rounded-full bg-ink-100 px-2.5 py-1 text-xs font-mono text-ink-700">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{highlights.length > 0 && (
|
||||
<Section icon={Leaf} label="Highlights">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{highlights.map((h, i) => (
|
||||
<span key={i} className="rounded-full bg-leaf-100 px-2.5 py-1 text-xs font-medium text-leaf-600">{h}</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{nutrients.length > 0 && (
|
||||
<Section icon={Leaf} label="Nutrients">
|
||||
<ul className="list-inside list-disc text-sm text-ink-800">
|
||||
{nutrients.map((n, i) => <li key={i}>{n.split(' - ')[0]}</li>)}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{providers.length > 0 && (
|
||||
<Section icon={Store} label="Available from">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{providers.map((p, i) => (
|
||||
<span key={i} className="rounded-full border border-ink-900/15 px-2.5 py-1 text-xs text-ink-700">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(recsLoading || (recommendations && recommendations.length > 0)) && (
|
||||
<Section icon={Sparkles} label="Customers also bought">
|
||||
{recsLoading ? (
|
||||
<p className="text-xs text-slate-400">Finding similar products…</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{recommendations.map((r) => (
|
||||
<button
|
||||
key={`${r.brand}-${r.image_id}`}
|
||||
onClick={() => onSelectRecommendation?.(r)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-ink-900/10 bg-white px-3 py-2 text-left text-sm transition hover:border-amber-400 hover:bg-amber-50"
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="font-medium text-ink-900">{prettifyImageId(r.image_id)}</span>
|
||||
<span className="ml-1.5 text-xs text-slate-400">{r.brand}</span>
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-leaf-100 px-2 py-0.5 text-[11px] font-medium text-leaf-600">
|
||||
{Math.round(r.similarity_score * 100)}% match
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Icon className="h-3.5 w-3.5" /> {label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/Sidebar.jsx
Normal file
100
frontend/src/components/Sidebar.jsx
Normal file
@@ -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 (
|
||||
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-ink-900/10 bg-ink-900 text-paper-100">
|
||||
<div className="flex items-center gap-2 px-5 py-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500 text-ink-950">
|
||||
<ShoppingBasket className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-sm font-bold leading-tight">Brand Catalog - Product LLM</p>
|
||||
<p className="text-[11px] text-paper-100/50">Brand Catalog Search</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dash-divider opacity-20" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<div className="mb-1 flex items-center justify-between px-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-paper-100/40">Brands</p>
|
||||
<button onClick={onRefreshBrands} className="text-paper-100/40 hover:text-amber-400" title="Refresh brands">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{brandsLoading ? (
|
||||
<div className="px-2 py-2"><Spinner label="Loading brands…" className="text-paper-100/60" /></div>
|
||||
) : brands.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-paper-100/40">
|
||||
No brands ingested yet. Run the seed script or the admin panel to add some.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => onSelectBrand(null)}
|
||||
className={`w-full rounded-lg px-2.5 py-1.5 text-left text-sm transition ${!selectedBrand ? 'bg-amber-500 text-ink-950 font-medium' : 'text-paper-100/80 hover:bg-ink-800'
|
||||
}`}
|
||||
>
|
||||
All brands
|
||||
</button>
|
||||
</li>
|
||||
{brands.map((b) => (
|
||||
<li key={b}>
|
||||
<button
|
||||
onClick={() => onSelectBrand(b)}
|
||||
className={`w-full truncate rounded-lg px-2.5 py-1.5 text-left text-sm transition ${selectedBrand === b ? 'bg-amber-500 text-ink-950 font-medium' : 'text-paper-100/80 hover:bg-ink-800'
|
||||
}`}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{selectedBrand && (
|
||||
<>
|
||||
<p className="mb-1 mt-5 flex items-center gap-1.5 px-2 text-[11px] font-semibold uppercase tracking-wide text-paper-100/40">
|
||||
<Tag className="h-3 w-3" /> Categories
|
||||
</p>
|
||||
{categoriesLoading ? (
|
||||
<div className="px-2 py-2"><Spinner label="Loading…" className="text-paper-100/60" /></div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => onSelectCategory(null)}
|
||||
className={`w-full rounded-lg px-2.5 py-1.5 text-left text-sm transition ${!selectedCategory ? 'bg-ink-700 text-paper-100' : 'text-paper-100/70 hover:bg-ink-800'
|
||||
}`}
|
||||
>
|
||||
All categories
|
||||
</button>
|
||||
</li>
|
||||
{categories.map((c) => (
|
||||
<li key={c}>
|
||||
<button
|
||||
onClick={() => onSelectCategory(c)}
|
||||
className={`w-full truncate rounded-lg px-2.5 py-1.5 text-left text-sm transition ${selectedCategory === c ? 'bg-ink-700 text-paper-100' : 'text-paper-100/70 hover:bg-ink-800'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/TopBar.jsx
Normal file
93
frontend/src/components/TopBar.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Search, MessageCircle, LayoutGrid, AlertTriangle, Wrench, Store, BarChart3, HeartPulse } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health }) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<header className="flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1 rounded-full bg-ink-100 p-1">
|
||||
<ModeButton icon={LayoutGrid} label="Browse & Search" active={mode === 'search'} onClick={() => onModeChange('search')} />
|
||||
<ModeButton icon={MessageCircle} label="Ask AI" active={mode === 'chat'} onClick={() => onModeChange('chat')} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{health && health.status !== 'ok' && (
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-maroon-100 px-3 py-1 text-xs font-medium text-maroon-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{!health.database && !health.ollama
|
||||
? 'DB & Ollama unreachable'
|
||||
: !health.database
|
||||
? 'Database unreachable'
|
||||
: 'Ollama unreachable'}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to="/stores"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/stores' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<Store className="h-3.5 w-3.5" /> Stores
|
||||
</Link>
|
||||
<Link
|
||||
to="/analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/analytics' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5" /> Analytics
|
||||
</Link>
|
||||
<Link
|
||||
to="/nutrition-analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/nutrition-analytics' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/admin' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" /> Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'search' && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search products by name, ingredient, or vibe — e.g. 'low sugar biscuit under 20 rupees'"
|
||||
className="w-full rounded-full border border-ink-900/10 bg-paper-50 py-2.5 pl-10 pr-4 text-sm text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({ icon: Icon, label, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium transition ${
|
||||
active ? 'bg-paper-50 text-ink-950 shadow-sm' : 'text-slate-500 hover:text-ink-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" /> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/nutrition/NutritionPanel.jsx
Normal file
224
frontend/src/components/nutrition/NutritionPanel.jsx
Normal file
@@ -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 (
|
||||
<div className={`flex flex-col items-center rounded-xl px-4 py-2 ${scoreColor(score)}`}>
|
||||
<span className="font-display text-xl font-bold">{score ?? '–'}</span>
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Section icon={Activity} label="Nutrition">
|
||||
<Spinner label="Loading nutrition data…" />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!nutrition || nutrition.data_status === 'unavailable') {
|
||||
return (
|
||||
<Section icon={Activity} label="Nutrition">
|
||||
<p className="text-xs text-slate-400">
|
||||
Nutrition data unavailable for {productName || 'this product'}. Verified nutrition facts
|
||||
couldn't be confidently matched from trusted sources yet.
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const source = perServing && nutrition.per_serving ? nutrition.per_serving : nutrition;
|
||||
const hasServing = nutrition.per_serving && Object.keys(nutrition.per_serving).length > 0;
|
||||
|
||||
return (
|
||||
<Section icon={Activity} label="Nutrition">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ScoreBadge label="Nutrition Score" score={nutrition.nutrition_score} />
|
||||
<ScoreBadge label="Health Score" score={nutrition.health_score} />
|
||||
{nutrition.nutrition_cluster_label && (
|
||||
<span className="rounded-full bg-ink-100 px-3 py-1 text-xs font-medium text-ink-700">
|
||||
{nutrition.nutrition_cluster_label}
|
||||
</span>
|
||||
)}
|
||||
{nutrition.data_status === 'partial' && (
|
||||
<span className="text-[11px] text-slate-400">Some values unverified for this product</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{nutrition.ai_summary && (
|
||||
<p className="rounded-lg bg-ink-50 p-3 text-sm leading-relaxed text-ink-800">{nutrition.ai_summary}</p>
|
||||
)}
|
||||
|
||||
{(nutrition.diet_tags?.length > 0 || nutrition.allergens?.length > 0) && (
|
||||
<div className="space-y-1.5">
|
||||
{nutrition.diet_tags?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{nutrition.diet_tags.map((t) => (
|
||||
<span key={t} className="rounded-full bg-leaf-100 px-2.5 py-1 text-xs font-medium text-leaf-600">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{nutrition.allergens?.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<ShieldAlert className="h-3.5 w-3.5 text-amber-600" />
|
||||
{nutrition.allergens.map((a) => (
|
||||
<span key={a} className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">Contains {a}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nutrition.positive_insights?.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{nutrition.positive_insights.map((p, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5 text-sm text-ink-800">
|
||||
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-leaf-600" /> {p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{nutrition.nutritional_cautions?.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{nutrition.nutritional_cautions.map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5 text-sm text-ink-800">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600" /> {c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">Nutrition Facts</span>
|
||||
{hasServing && (
|
||||
<div className="flex overflow-hidden rounded-full border border-ink-900/15 text-[11px]">
|
||||
<button
|
||||
onClick={() => setPerServing(false)}
|
||||
className={`px-2.5 py-1 ${!perServing ? 'bg-ink-900 text-white' : 'text-slate-500'}`}
|
||||
>Per 100g</button>
|
||||
<button
|
||||
onClick={() => setPerServing(true)}
|
||||
className={`px-2.5 py-1 ${perServing ? 'bg-ink-900 text-white' : 'text-slate-500'}`}
|
||||
>Per Serving</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 rounded-lg border border-ink-900/10 bg-white p-3 text-sm">
|
||||
{CORE_NUTRIENTS.map(([key, label, unit, decimals]) => {
|
||||
const value = source[key];
|
||||
if (value === null || value === undefined) return null;
|
||||
return (
|
||||
<div key={key} className="flex justify-between border-b border-ink-900/5 py-0.5 last:border-0">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<span className="font-mono text-ink-900">{Number(value).toFixed(decimals)} {unit}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{nutrition.off_nutriscore && (
|
||||
<p className="mt-1 text-[11px] text-slate-400">Nutri-Score (external): {nutrition.off_nutriscore.toUpperCase()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{alternatives?.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<ArrowRightLeft className="h-3.5 w-3.5" /> Healthier Alternatives
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{alternatives.map((a) => (
|
||||
<div key={`${a.brand}-${a.image_id}`} className="rounded-lg border border-ink-900/10 bg-white px-3 py-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-ink-900">{a.product_name || a.image_id}</span>
|
||||
<span className="rounded-full bg-leaf-100 px-2 py-0.5 text-[11px] font-medium text-leaf-600">+{a.health_score_delta}</span>
|
||||
</div>
|
||||
{a.reason && <p className="mt-0.5 text-xs text-slate-400">{a.reason}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{similar?.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Similar Products (by Nutrition)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{similar.map((s) => (
|
||||
<span key={`${s.brand}-${s.image_id}`} className="rounded-full border border-ink-900/15 px-2.5 py-1 text-xs text-ink-700">
|
||||
{s.image_id} · {Math.round(s.similarity_score * 100)}% match
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
88
frontend/src/index.css
Normal file
88
frontend/src/index.css
Normal file
@@ -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;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
132
frontend/src/pages/AdminPage.jsx
Normal file
132
frontend/src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
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 [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);
|
||||
|
||||
useEffect(() => {
|
||||
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 handleSubmit(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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-10">
|
||||
<Link to="/" className="mb-6 flex items-center gap-1.5 text-sm text-slate-500 hover:text-ink-900">
|
||||
<ArrowLeft className="h-4 w-4" /> Back to catalog
|
||||
</Link>
|
||||
|
||||
<h1 className="font-display text-2xl font-bold text-ink-950">Catalog ingestion</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Discover and store a new brand's products in pgvector (discovery → images → embeddings).
|
||||
This calls the local Ollama model repeatedly and can take a few minutes per brand on
|
||||
CPU-only hardware - avoid running this at the same time as heavy chat traffic.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-3 rounded-xl border border-ink-900/10 bg-paper-50 p-5 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Brand name</label>
|
||||
<input
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
placeholder="e.g. Britannia"
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Max products</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={maxProducts}
|
||||
onChange={(e) => setMaxProducts(e.target.value)}
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !brand.trim() || !!runningJob}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-sm font-medium text-paper-100 transition hover:bg-ink-800 disabled:opacity-40"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" /> Start ingestion
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{runningJob && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
A job for "{runningJob.brand}" is already in progress - wait for it to finish before starting another.
|
||||
</p>
|
||||
)}
|
||||
{submitError && <p className="mt-2 text-sm text-maroon-600">{submitError}</p>}
|
||||
|
||||
<h2 className="mb-3 mt-8 font-display text-sm font-semibold uppercase tracking-wide text-slate-500">Recent jobs</h2>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No ingestion jobs started yet this session.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{jobs.map((j) => {
|
||||
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<li key={j.job_id} className="flex items-center gap-3 rounded-lg border border-ink-900/10 bg-paper-50 px-4 py-3">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
|
||||
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-ink-950">{j.brand}</p>
|
||||
<p className="text-xs text-slate-500">{j.detail || j.status}</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
frontend/src/pages/AnalyticsPage.jsx
Normal file
237
frontend/src/pages/AnalyticsPage.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } 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 } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
|
||||
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');
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<PageHeader />
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
||||
{comparisonLoading ? (
|
||||
<Spinner label="Loading chain analytics…" />
|
||||
) : !comparison || stores.length === 0 ? (
|
||||
<EmptyState icon={TrendingUp} title="No analytics yet" subtitle="Seed and train the store intelligence models first (see /stores page)." />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Revenue by store" icon={Trophy}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={stores}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(v) => `₹${Number(v).toLocaleString('en-IN')}`} />
|
||||
<Bar dataKey="revenue" fill="#e2a33d" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Profit by store" icon={Award}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={stores}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(v) => `₹${Number(v).toLocaleString('en-IN')}`} />
|
||||
<Bar dataKey="profit" fill="#3f8556" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Orders vs. average order value" icon={TrendingUp}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={stores}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line yAxisId="left" type="monotone" dataKey="order_count" name="Orders" stroke="#2c3e63" strokeWidth={2} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="avg_order_value" name="Avg order value (₹)" stroke="#c2872a" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Simulated footfall share" icon={Flame}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={stores} dataKey="footfall_simulated" nameKey="store_id" outerRadius={90} label={(e) => e.store_id}>
|
||||
{stores.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<Badge label="Best performing" value={comparison.best_performing} />
|
||||
<Badge label="Highest revenue" value={comparison.highest_revenue} />
|
||||
<Badge label="Highest profit" value={comparison.highest_profit} />
|
||||
<Badge label="Lowest performing" value={comparison.lowest_performing} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Flame className="h-4 w-4 text-amber-500" /> Trending products
|
||||
</h3>
|
||||
<div className="flex gap-1 rounded-full bg-ink-100 p-0.5 text-xs">
|
||||
{['today', 'weekly', 'monthly'].map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setWindow(w)}
|
||||
className={`rounded-full px-2.5 py-1 capitalize transition ${window_ === w ? 'bg-paper-50 font-medium text-ink-950 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{trendingLoading ? (
|
||||
<Spinner label="Scoring products…" />
|
||||
) : trending.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">No trending data yet - train the trending model first.</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{trending.map((t) => (
|
||||
<li key={`${t.brand}-${t.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-amber-50/50">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{t.rank}</span>
|
||||
<span className="truncate text-ink-900">{prettify(t.image_id)}</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">{t.brand}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-600">
|
||||
{t.trend_score.toFixed(1)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Trophy className="h-4 w-4 text-leaf-600" /> Top products
|
||||
</h3>
|
||||
<div className="flex gap-1 rounded-full bg-ink-100 p-0.5 text-xs">
|
||||
{['revenue', 'units'].map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setTopBy(b)}
|
||||
className={`rounded-full px-2.5 py-1 capitalize transition ${topBy === b ? 'bg-paper-50 font-medium text-ink-950 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
by {b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{topProducts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">No sales data yet.</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{topProducts.map((p, i) => (
|
||||
<li key={`${p.brand}-${p.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-leaf-100/40">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{i + 1}</span>
|
||||
<span className="truncate text-ink-900">{prettify(p.image_id)}</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">{p.brand}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ink-700">
|
||||
{topBy === 'revenue' ? `₹${Number(p.revenue).toLocaleString('en-IN')}` : `${p.units_sold} units`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-amber-500" /> {title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ label, value }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<span className="rounded-full border border-ink-900/10 bg-white px-3 py-1">
|
||||
<span className="text-slate-400">{label}:</span> <span className="font-medium text-ink-900">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="font-display text-lg font-bold text-ink-950">Analytics</h1>
|
||||
<p className="text-xs text-slate-400">Store comparison, trending products & top sellers</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
|
||||
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
|
||||
<Link to="/stores" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Stores</Link>
|
||||
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Analytics</span>
|
||||
<Link to="/nutrition-analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Nutrition</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
190
frontend/src/pages/HomePage.jsx
Normal file
190
frontend/src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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 (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
brands={brands}
|
||||
brandsLoading={brandsLoading}
|
||||
categories={categories}
|
||||
categoriesLoading={categoriesLoading}
|
||||
selectedBrand={selectedBrand}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectBrand={(b) => { setSelectedBrand(b); setQuery(''); }}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
onRefreshBrands={loadBrands}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onSubmit={runSearch}
|
||||
health={health}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{mode === 'search' ? (
|
||||
<div className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="font-display text-sm font-semibold text-ink-900">
|
||||
{hasSearched
|
||||
? `Results for "${query}"`
|
||||
: selectedBrand
|
||||
? `Browsing ${selectedBrand}${selectedCategory ? ` · ${selectedCategory}` : ''}`
|
||||
: 'Browsing All Brands'}
|
||||
</p>
|
||||
{products.length > 0 && <p className="text-xs text-slate-500">{products.length} shown</p>}
|
||||
</div>
|
||||
|
||||
<ProductGrid
|
||||
products={products}
|
||||
loading={productsLoading}
|
||||
error={productsError}
|
||||
showSimilarity={hasSearched}
|
||||
emptyTitle={brands.length === 0 ? 'No catalog data yet' : 'No products found'}
|
||||
emptySubtitle={
|
||||
brands.length === 0
|
||||
? "Run `python scripts/seed_sample_data.py` in the backend to load sample brands, or use the Admin panel to ingest a real one."
|
||||
: 'Try a different brand, category, or search term.'
|
||||
}
|
||||
onSelect={setSelectedProduct}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ChatPanel brand={selectedBrand} onSelectSource={setSelectedProduct} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ProductModal
|
||||
product={selectedProduct}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
onSelectRecommendation={(r) =>
|
||||
api.getProductDetail(r.brand, r.image_id).then(setSelectedProduct).catch(() => {})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
frontend/src/pages/NutritionAnalyticsPage.jsx
Normal file
209
frontend/src/pages/NutritionAnalyticsPage.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
|
||||
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 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 (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<PageHeader />
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div>
|
||||
<h3 className="font-display text-sm font-semibold text-ink-950">Nutrition data pipeline</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{hasData
|
||||
? `${enrichmentCounts.verified || 0} verified · ${enrichmentCounts.partial || 0} partial · ${enrichmentCounts.unavailable || 0} unavailable of ${totalTracked} tracked products`
|
||||
: 'No products enriched yet - run enrichment to retrieve verified nutrition data from Open Food Facts.'}
|
||||
</p>
|
||||
{job && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
{job.kind === 'enrich' ? 'Enrichment' : 'Training'} {job.status}
|
||||
{job.total ? ` (${job.processed}/${job.total})` : ''}
|
||||
{job.detail ? ` - ${job.detail}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={runEnrich}
|
||||
disabled={job && job.status !== 'done' && job.status !== 'failed'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-4 py-2 text-xs font-medium text-paper-100 disabled:opacity-40"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" /> Run enrichment
|
||||
</button>
|
||||
<button
|
||||
onClick={runTrain}
|
||||
disabled={job && job.status !== 'done' && job.status !== 'failed'}
|
||||
className="flex items-center gap-1.5 rounded-full border border-ink-900/15 px-4 py-2 text-xs font-medium text-ink-900 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Train similarity/clustering
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner label="Loading nutrition analytics…" />
|
||||
) : !hasData ? (
|
||||
<EmptyState
|
||||
icon={HeartPulse}
|
||||
title="No nutrition analytics yet"
|
||||
subtitle="Run enrichment above (or `python scripts/enrich_nutrition.py`), then training, to populate this dashboard."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Healthiest brands" icon={Award}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dashboard.rankings.healthiest_brands}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="brand" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="avg_health_score" fill="#3f8556" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Healthiest categories" icon={Apple}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dashboard.rankings.healthiest_categories}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="category" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="avg_health_score" fill="#e2a33d" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{distributionData.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ChartCard title="Health score distribution" icon={HeartPulse}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={distributionData} dataKey="count" nameKey="bucket" outerRadius={85} label={(e) => e.bucket}>
|
||||
{distributionData.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<Leaderboard title="Highest protein" icon={Beef} items={dashboard.leaderboards.highest_protein} field="protein_g" unit="g" />
|
||||
<Leaderboard title="Highest fiber" icon={Wheat} items={dashboard.leaderboards.highest_fiber} field="dietary_fiber_g" unit="g" />
|
||||
<Leaderboard title="Lowest sugar" icon={Droplet} items={dashboard.leaderboards.lowest_sugar} field="total_sugar_g" unit="g" />
|
||||
<Leaderboard title="Lowest sodium" icon={Flame} items={dashboard.leaderboards.lowest_sodium} field="sodium_mg" unit="mg" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-amber-500" /> {title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Leaderboard({ title, icon: Icon, items = [], field, unit }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-3 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-leaf-600" /> {title}
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-slate-400">No data yet.</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{items.map((p, i) => (
|
||||
<li key={`${p.brand}-${p.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-amber-50/50">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{i + 1}</span>
|
||||
<span className="truncate text-ink-900">{p.product_name || p.image_id}</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">{p.brand}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-600">
|
||||
{p[field] != null ? `${Number(p[field]).toFixed(1)} ${unit}` : '–'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="font-display text-lg font-bold text-ink-950">Nutrition Analytics</h1>
|
||||
<p className="text-xs text-slate-400">Verified nutrition data, health scores & leaderboards across the catalog</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
|
||||
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
|
||||
<Link to="/stores" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Stores</Link>
|
||||
<Link to="/analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Analytics</Link>
|
||||
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Nutrition</span>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
227
frontend/src/pages/StoresPage.jsx
Normal file
227
frontend/src/pages/StoresPage.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX,
|
||||
PackageSearch, BarChart3, Search,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
|
||||
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);
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen w-full flex-col bg-paper-100">
|
||||
<PageHeader />
|
||||
|
||||
{storesLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center"><Spinner label="Loading stores…" /></div>
|
||||
) : stores.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<EmptyState
|
||||
icon={StoreIcon}
|
||||
title="No stores set up yet"
|
||||
subtitle="Run scripts/seed_store_intelligence.py (or POST /api/admin/store-intelligence/seed) to provision the 5 stores and simulate order history, then scripts/train_ml_models.py to train the discount/trending/recommendation models."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex gap-2 overflow-x-auto border-b border-ink-900/10 bg-paper-50 px-6 py-3">
|
||||
{stores.map((s) => (
|
||||
<button
|
||||
key={s.store_id}
|
||||
onClick={() => setSelectedStore(s.store_id)}
|
||||
className={`flex shrink-0 items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition ${
|
||||
selectedStore === s.store_id ? 'bg-ink-900 text-paper-100' : 'bg-ink-100 text-ink-700 hover:bg-ink-100/70'
|
||||
}`}
|
||||
>
|
||||
<StoreIcon className="h-3.5 w-3.5" />
|
||||
{s.store_name}
|
||||
<span className="rounded-full bg-black/10 px-1.5 py-0.5 text-[10px] uppercase">{s.tier}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{dashboardLoading ? (
|
||||
<Spinner label="Loading store analytics…" className="mb-4" />
|
||||
) : dashboard ? (
|
||||
<KpiRow dashboard={dashboard} />
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-950">Products in this store</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<input type="checkbox" checked={inStockOnly} onChange={(e) => setInStockOnly(e.target.checked)} />
|
||||
In stock only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productsLoading ? (
|
||||
<Spinner label="Loading products…" className="mt-4" />
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<EmptyState icon={PackageSearch} title="No products found" subtitle="Try clearing filters." />
|
||||
) : (
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-ink-900/10 bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-ink-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">Product</th>
|
||||
<th className="px-4 py-2.5">Category</th>
|
||||
<th className="px-4 py-2.5">Stock</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">MRP</th>
|
||||
<th className="px-4 py-2.5">Selling Price</th>
|
||||
<th className="px-4 py-2.5">Discount</th>
|
||||
<th className="px-4 py-2.5">Margin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-900/5">
|
||||
{filteredProducts.map((p) => {
|
||||
const d = discounts[`${p.brand}||${p.image_id}`];
|
||||
return (
|
||||
<tr key={`${p.brand}-${p.image_id}`} className="hover:bg-amber-50/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-ink-900">{p.title}</p>
|
||||
<p className="text-xs text-slate-400">{p.brand}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-500">{p.category}</td>
|
||||
<td className="px-4 py-2.5 font-mono">{p.available_stock}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${STOCK_STATUS_STYLES[p.stock_status] || 'bg-ink-100 text-ink-700'}`}>
|
||||
{p.stock_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-slate-500 line-through decoration-slate-300">₹{p.mrp}</td>
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-ink-950">₹{p.selling_price}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{d && d.discount_pct > 0 ? (
|
||||
<span className="rounded-full bg-maroon-100 px-2 py-0.5 text-[11px] font-medium text-maroon-600">
|
||||
-{d.discount_pct}% → ₹{d.final_price}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-leaf-600">{p.gross_profit_pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||
<c.icon className="h-3.5 w-3.5" /> {c.label}
|
||||
</div>
|
||||
<p className={`mt-1.5 font-display text-xl font-bold ${TONE_TEXT[c.tone]}`}>{c.value}</p>
|
||||
{c.sub && <p className="text-xs text-slate-400">{c.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="font-display text-lg font-bold text-ink-950">Store Intelligence</h1>
|
||||
<p className="text-xs text-slate-400">Multi-store inventory, pricing & ML-based discounts</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
|
||||
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
|
||||
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Stores</span>
|
||||
<Link to="/analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Analytics</Link>
|
||||
<Link to="/nutrition-analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Nutrition</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Avoid CORS entirely in dev by proxying /api/* straight to FastAPI.
|
||||
// Adjust the target if your backend runs on a different host/port.
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user