This commit is contained in:
sriram
2026-08-11 19:14:03 +05:30
commit 3765e666bc
35 changed files with 7024 additions and 0 deletions

108
src/App.jsx Normal file
View File

@@ -0,0 +1,108 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import { LoginPage } from './pages/LoginPage';
import { HomePage } from './pages/HomePage';
import { UserPage } from './pages/UserPage';
import { AdminPage } from './pages/AdminPage';
import { StoresPage } from './pages/StoresPage';
import { AnalyticsPage } from './pages/AnalyticsPage';
import { NutritionAnalyticsPage } from './pages/NutritionAnalyticsPage';
function ProtectedRoute({ children, allowedRoles }) {
const { user, role } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
const effectiveRole = role || (user.role === 'store' ? 'user' : user.role);
if (allowedRoles && !allowedRoles.includes(effectiveRole)) {
if (window.location.pathname === '/user') {
return children;
}
return <Navigate to="/user" replace />;
}
return children;
}
export default function App() {
return (
<ErrorBoundary>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public Login Route */}
<Route path="/login" element={<LoginPage />} />
{/* Catalog Route - RESTRICTED TO ADMIN ONLY */}
<Route
path="/"
element={
<ProtectedRoute allowedRoles={['admin']}>
<HomePage />
</ProtectedRoute>
}
/>
{/* User Workspace Route */}
<Route
path="/user"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<UserPage />
</ProtectedRoute>
}
/>
{/* Admin Panel Route - RESTRICTED TO ADMIN ONLY */}
<Route
path="/admin"
element={
<ProtectedRoute allowedRoles={['admin']}>
<AdminPage />
</ProtectedRoute>
}
/>
{/* Store Intelligence Route */}
<Route
path="/stores"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<StoresPage />
</ProtectedRoute>
}
/>
{/* Analytics Route */}
<Route
path="/analytics"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<AnalyticsPage />
</ProtectedRoute>
}
/>
{/* Nutrition Intelligence Route */}
<Route
path="/nutrition-analytics"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<NutritionAnalyticsPage />
</ProtectedRoute>
}
/>
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ErrorBoundary>
);
}

183
src/api/client.js Normal file
View File

@@ -0,0 +1,183 @@
const BASE = import.meta.env.VITE_API_BASE_URL || '';
class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function request(path, options = {}) {
let res;
try {
res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
});
} catch {
throw new ApiError(
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
`(uvicorn app.main:app) and reachable?`,
0
);
}
if (!res.ok) {
let detail = `Request failed (${res.status})`;
try {
const body = await res.json();
detail = body.detail || JSON.stringify(body);
} catch {
/* ignore parse errors, keep generic message */
}
throw new ApiError(detail, res.status);
}
if (res.status === 204) return null;
return res.json();
}
function qs(params = {}) {
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') usp.set(k, v);
});
const s = usp.toString();
return s ? `?${s}` : '';
}
// Browse endpoints intentionally request a very high `limit` explicitly
// (rather than relying on the backend's default) so that the UI always
// shows the FULL catalog for a brand / for "All brands", however many
// thousands of products that ends up being. Do not lower this to a small
// page size unless you also add real pagination controls to the grid -
// silently truncating the product list here was the exact bug this
// constant is guarding against.
const SHOW_ALL_PRODUCTS_LIMIT = 100000;
export const api = {
getHealth: () => request('/api/health'),
getBrands: () => request('/api/brands'),
getBrandCategories: (brand) => request(`/api/brands/${encodeURIComponent(brand)}/categories`),
getBrandProducts: (brand, { category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
request(`/api/brands/${encodeURIComponent(brand)}/products${qs({ category, limit, offset })}`),
getAllProducts: ({ category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
request(`/api/products${qs({ category, limit, offset })}`),
getProductDetail: (brand, imageId) =>
request(`/api/brands/${encodeURIComponent(brand)}/products/${encodeURIComponent(imageId)}`),
search: (q, { brand, category, top_k = 50 } = {}) =>
request(`/api/search${qs({ q, brand, category, top_k })}`),
chat: ({ query, brand, category, top_k, history }) =>
request('/api/chat', {
method: 'POST',
body: JSON.stringify({ query, brand, category, top_k, history }),
}),
generateCatalog: ({ brand, max_products = 50 }) =>
request('/api/catalog/generate', {
method: 'POST',
body: JSON.stringify({ brand, max_products }),
}),
getJobStatus: (jobId) => request(`/api/catalog/jobs/${jobId}`),
// --- Store Intelligence (v3.0) ---
getStores: () => request('/api/stores'),
getStore: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}`),
getStoreProducts: (storeId, { category, in_stock_only, limit = 500, offset = 0 } = {}) =>
request(`/api/stores/${encodeURIComponent(storeId)}/products${qs({ category, in_stock_only, limit, offset })}`),
getProductAcrossStores: (brand, imageId) =>
request(`/api/products/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/stores`),
getStoreDiscounts: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}/discounts`),
getProductDiscount: (storeId, brand, imageId) =>
request(`/api/stores/${encodeURIComponent(storeId)}/discounts/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
getTrending: ({ window = 'weekly', scope = 'overall', scope_value, top_k = 10 } = {}) =>
request(`/api/trending${qs({ window, scope, scope_value, top_k })}`),
getRecommendations: (brand, imageId, topK = 5) =>
request(`/api/recommendations/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}${qs({ top_k: topK })}`),
getStoreAnalytics: (storeId) => request(`/api/analytics/store/${encodeURIComponent(storeId)}`),
getStoreComparison: () => request('/api/analytics/compare'),
getProductAnalytics: (brand, imageId) => request(`/api/analytics/product/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
getTopProducts: ({ by = 'revenue', order = 'top', limit = 10 } = {}) =>
request(`/api/analytics/top-products${qs({ by, order, limit })}`),
seedStoreIntelligence: ({ reset_orders = true, days = 90, seed = 42 } = {}) =>
request('/api/admin/store-intelligence/seed', { method: 'POST', body: JSON.stringify({ reset_orders, days, seed }) }),
trainStoreModels: ({ models } = {}) =>
request('/api/admin/store-intelligence/train', { method: 'POST', body: JSON.stringify({ models: models || null }) }),
getStoreIntelligenceJob: (jobId) => request(`/api/admin/store-intelligence/jobs/${jobId}`),
// --- AI Nutritional Intelligence Module ---
getNutrition: (brand, imageId) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
getNutritionHealthScore: (brand, imageId) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/health-score`),
getNutritionInsights: (brand, imageId) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/insights`),
getNutritionAlternatives: (brand, imageId, topK = 5) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/alternatives${qs({ top_k: topK })}`),
getNutritionSimilar: (brand, imageId, topK = 5) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/similar${qs({ top_k: topK })}`),
compareNutrition: (products) => request(`/api/nutrition/compare${qs({ products: products.join(',') })}`),
getDietCompatibleProducts: (tag, { category, exclude_allergen, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/diet/${encodeURIComponent(tag)}${qs({ category, exclude_allergen, limit, offset })}`),
getHighProteinProducts: ({ category, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/high-protein${qs({ category, limit, offset })}`),
getLowSugarProducts: ({ category, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/low-sugar${qs({ category, limit, offset })}`),
getHighFiberProducts: ({ category, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/high-fiber${qs({ category, limit, offset })}`),
filterNutritionProducts: ({ sort_by = 'health_score', order = 'desc', category, diet_tag, exclude_allergen, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/products${qs({ sort_by, order, category, diet_tag, exclude_allergen, limit, offset })}`),
getNutritionAnalyticsDashboard: (limit = 10) => request(`/api/nutrition/analytics/dashboard${qs({ limit })}`),
getNutritionLeaderboards: (limit = 10) => request(`/api/nutrition/analytics/leaderboards${qs({ limit })}`),
getNutritionRankings: (limit = 10) => request(`/api/nutrition/analytics/rankings${qs({ limit })}`),
getNutritionDistribution: () => request('/api/nutrition/analytics/distribution'),
getPersonalizedNutritionRecommendations: (customerId, topK = 8) =>
request(`/api/nutrition/recommendations/${encodeURIComponent(customerId)}${qs({ top_k: topK })}`),
enrichNutrition: ({ skip_if_verified = true, generate_narrative = true, max_products } = {}) =>
request('/api/admin/nutrition-intelligence/enrich', {
method: 'POST', body: JSON.stringify({ skip_if_verified, generate_narrative, max_products: max_products || null }),
}),
trainNutritionModels: () => request('/api/admin/nutrition-intelligence/train', { method: 'POST', body: JSON.stringify({}) }),
getNutritionIntelligenceJob: (jobId) => request(`/api/admin/nutrition-intelligence/jobs/${jobId}`),
getNutritionEnrichmentStatus: () => request('/api/admin/nutrition-intelligence/status'),
// --- Excel / CSV Upload API ---
uploadFile: async (tabType, file) => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${BASE}/api/upload/${encodeURIComponent(tabType)}`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
let detail = `Upload failed (${res.status})`;
try {
const body = await res.json();
detail = body.detail || JSON.stringify(body);
} catch {}
throw new ApiError(detail, res.status);
}
return res.json();
},
getTemplateUrl: (tabType) => `${BASE}/api/upload/template/${encodeURIComponent(tabType)}`,
};
export { ApiError };

1
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
src/components/Atoms.jsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
export class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="p-8 m-4 rounded-2xl border border-rose-500/30 bg-rose-50/50 text-slate-900 shadow-lg text-center space-y-4">
<div className="flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-500/20 text-rose-600">
<AlertTriangle className="h-6 w-6" />
</div>
</div>
<div>
<h3 className="text-base font-bold text-rose-950">Something went wrong in this section</h3>
<p className="text-xs text-rose-700 mt-1 max-w-md mx-auto">
{this.state.error?.message || 'An unexpected rendering error occurred.'}
</p>
</div>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs shadow-md transition cursor-pointer"
>
<RefreshCw className="h-4 w-4" /> Reload Page
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,188 @@
import React from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
export function NavigationHeader({
title,
subtitle,
icon: TitleIcon,
health,
onResetHome,
children,
}) {
const location = useLocation();
const path = location.pathname;
const search = location.search;
const navigate = useNavigate();
const { user, role, logout } = useAuth();
const handleLogout = () => {
logout();
navigate('/login');
};
const getRoleBadge = () => {
if (role === 'admin') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 border border-amber-500/30 px-2.5 py-0.5 text-[11px] font-bold text-amber-600">
<ShieldCheck className="h-3 w-3" /> Admin
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/15 border border-blue-500/30 px-2.5 py-0.5 text-[11px] font-bold text-blue-600">
<UserCheck className="h-3 w-3" /> User
</span>
);
};
return (
<header className="sticky top-0 z-30 flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/95 px-6 py-3.5 backdrop-blur-md transition-all shadow-xs">
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Left Section: Home/Back Button + Page Title */}
<div className="flex items-center gap-3">
<Link
to={role === 'admin' ? '/' : '/user'}
onClick={() => {
if (onResetHome) onResetHome();
}}
title={role === 'admin' ? 'Navigate to Catalog' : 'Navigate to User Workspace'}
className="group flex items-center gap-2 rounded-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white px-4 py-2 text-xs font-semibold shadow-sm transition-all duration-200 active:scale-95 hover:shadow-md"
>
<Home className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
<span>{role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'}</span>
</Link>
{title && (
<div className="flex items-center gap-2.5 border-l border-ink-900/15 pl-4 py-0.5">
{TitleIcon && (
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-ink-900/5 text-ink-900">
<TitleIcon className="h-4 w-4" />
</div>
)}
<div>
<h1 className="font-display text-sm font-bold text-ink-950 flex items-center gap-1.5">
{title}
</h1>
{subtitle && <p className="text-[11px] text-slate-400 font-normal leading-tight">{subtitle}</p>}
</div>
</div>
)}
</div>
{/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */}
<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 offline'
: !health.database
? 'Database offline'
: 'Ollama offline'}
</span>
)}
{/* User Role Profile Badge */}
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-2xs">
<span className="font-bold text-slate-800">{user?.display_name || user?.username || 'Guest'}</span>
{getRoleBadge()}
</div>
{/* Role-tailored Navigation Tabs */}
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
{/* Catalog tab ONLY visible to Admin role */}
{role === 'admin' && (
<Link
to="/"
onClick={() => {
if (onResetHome) onResetHome();
}}
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
</Link>
)}
{/* User Upload tab */}
<Link
to="/user"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/user' && !search.includes('tab=cards') ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<UserCheck className="h-3.5 w-3.5 text-blue-400" /> User Upload
</Link>
{/* Product Cards tab for User role */}
<Link
to="/user?tab=cards"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/user' && search.includes('tab=cards') ? 'bg-amber-500 text-slate-950 font-bold shadow-sm' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<LayoutGrid className="h-3.5 w-3.5 text-amber-600" /> Product Cards
</Link>
{/* Stores tab */}
<Link
to="/stores"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/stores' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<Store className="h-3.5 w-3.5 text-emerald-400" /> Stores
</Link>
{/* Analytics tab */}
<Link
to="/analytics"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/analytics' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<BarChart3 className="h-3.5 w-3.5" /> Analytics
</Link>
{/* Nutrition tab */}
<Link
to="/nutrition-analytics"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/nutrition-analytics' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
</Link>
{/* Admin Panel tab ONLY visible to Admin role */}
{role === 'admin' && (
<Link
to="/admin"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<Wrench className="h-3.5 w-3.5 text-amber-400" /> Admin
</Link>
)}
</nav>
{/* Top Right Logout Button */}
<button
onClick={handleLogout}
title="Logout to Login Page"
className="flex items-center gap-1.5 rounded-full bg-rose-600 hover:bg-rose-700 text-white px-3.5 py-1.5 text-xs font-semibold shadow-xs transition-all active:scale-95 cursor-pointer"
>
<LogOut className="h-3.5 w-3.5" />
<span>Logout</span>
</button>
</div>
</div>
{children}
</header>
);
}

View File

@@ -0,0 +1,195 @@
import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode, Images } from 'lucide-react';
import { useState, useMemo } from 'react';
const FOOD_CATEGORIES = [
'biscuit', 'cookie', 'cracker', 'rusk',
'cake', 'muffin', 'bakery', 'bread',
'dairy', 'milk', 'cheese', 'butter', 'ghee', 'paneer', 'yogurt',
'chocolate', 'cocoa',
'snack', 'chips', 'namkeen',
'noodle', 'pasta', 'maggi',
'grain', 'rice', 'wheat', 'flour', 'atta',
'oil', 'cooking oil',
'spice', 'masala',
'beverage', 'tea', 'coffee', 'juice', 'drink',
'jam', 'spread', 'honey',
'sauce', 'ketchup', 'pickle',
'frozen food',
'nutrition', 'health drink', 'cereal',
'baby food',
'food',
];
function isFoodCategory(category) {
if (!category || typeof category !== 'string') return false;
const cat = category.toLowerCase();
return FOOD_CATEGORIES.some((kw) => cat.includes(kw));
}
export function ProductCard({ product, onClick, similarity }) {
if (!product) return null;
const {
product_name = 'Product',
brand = 'Brand',
category = '',
price_range,
highlights = [],
providers = [],
image_url,
image_urls = [],
nutrients = [],
fssai_license,
product_sku,
sku_source,
hsn_code,
final_selling_price,
selling_price,
barcode,
barcode_type
} = product;
const displayHsnCode = hsn_code || product.hsnCode || null;
const allImages = useMemo(() => {
const list = [];
if (image_url && typeof image_url === 'string') list.push(image_url);
if (Array.isArray(image_urls)) {
for (const u of image_urls) {
if (u && typeof u === 'string' && !list.includes(u)) list.push(u);
}
}
return list;
}, [image_url, image_urls]);
const [imgIndex, setImgIndex] = useState(0);
const [imgError, setImgError] = useState(false);
const showNutrients = useMemo(() => isFoodCategory(category) && Array.isArray(nutrients) && nutrients.length > 0, [category, nutrients]);
const imageCount = allImages.length;
const currentDisplayUrl = allImages[imgIndex];
const displayPrice = final_selling_price ?? selling_price;
const handleImgError = () => {
if (imgIndex + 1 < allImages.length) {
setImgIndex((prev) => prev + 1);
} else {
setImgError(true);
}
};
return (
<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 cursor-pointer"
>
{currentDisplayUrl && !imgError ? (
<div className="-mx-4 -mt-4 -ml-6 mb-1 relative overflow-hidden rounded-t-xl">
<img
key={currentDisplayUrl}
src={currentDisplayUrl}
alt={String(product_name)}
className="h-40 w-full object-contain bg-ink-50 transition duration-300 group-hover:scale-105"
onError={handleImgError}
/>
{imageCount > 1 && (
<span className="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-ink-950/70 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm shadow">
<Images className="h-3 w-3" />
{imageCount}
</span>
)}
</div>
) : (
<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>
)}
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-display text-[15px] font-semibold leading-snug text-ink-950">{String(product_name)}</p>
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500">
{String(brand)} {category ? `· ${String(category)}` : ''}
</p>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
{barcode && (
<div className="flex items-center gap-1 text-[11px] font-mono font-medium text-slate-600">
<Barcode className="h-3.5 w-3.5 text-slate-500 shrink-0" />
<span>Barcode: {String(barcode)}</span>
</div>
)}
{product_sku && String(product_sku) !== String(barcode) && (
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>SKU: {String(product_sku)}</span>
</div>
)}
{displayHsnCode && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono text-slate-500">
HSN: {String(displayHsnCode)}
</span>
)}
</div>
</div>
{similarity != null && (
<span className="shrink-0 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
{Math.round(similarity * 100)}% match
</span>
)}
</div>
<div className="mt-auto pt-2 flex flex-col gap-1.5 border-t border-ink-900/5">
<div className="flex items-baseline justify-between">
{displayPrice != null ? (
<span className="font-mono text-base font-extrabold text-leaf-600">
₹{Number(displayPrice).toFixed(2)}
</span>
) : price_range ? (
<span className="font-mono text-xs font-semibold text-slate-600">
{String(price_range)}
</span>
) : (
<span className="text-xs text-slate-400">Price N/A</span>
)}
{fssai_license && (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600 font-medium shrink-0" title={`FSSAI Lic. No: ${fssai_license}`}>
<ShieldCheck className="h-3 w-3 shrink-0" /> FSSAI: {fssai_license}
</span>
)}
</div>
{showNutrients && (
<div className="flex flex-wrap items-center gap-1 pt-1">
<Leaf className="h-3 w-3 text-leaf-600 shrink-0" />
{nutrients.slice(0, 3).map((n, i) => {
const formatted = typeof n === 'string' ? n : (n?.name && n?.amount ? `${n.name}: ${n.amount}` : (n?.name || n?.nutrient || String(n)));
return (
<span key={i} className="rounded bg-leaf-50 px-1.5 py-0.5 text-[10px] text-leaf-700 border border-leaf-200">
{formatted}
</span>
);
})}
</div>
)}
{Array.isArray(highlights) && highlights.length > 0 && (
<div className="flex flex-wrap gap-1">
{highlights.slice(0, 2).map((h, i) => (
<span key={i} className="rounded-full bg-ink-900/5 px-2 py-0.5 text-[10px] text-slate-600">
{String(h)}
</span>
))}
</div>
)}
{Array.isArray(providers) && providers.length > 0 && (
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Store className="h-3 w-3 shrink-0" />
<span className="truncate">{providers.map(String).join(' · ')}</span>
</div>
)}
</div>
</button>
);
}

View 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>
);
}

View File

@@ -0,0 +1,274 @@
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images, ShieldCheck } from 'lucide-react';
import { useEffect, useState, useMemo } from 'react';
import { api } from '../api/client';
import { NutritionPanel } from './nutrition/NutritionPanel';
export function ProductModal({ product, onClose, onSelectRecommendation }) {
const [currentImgIndex, setCurrentImgIndex] = useState(0);
const [failedIndices, setFailedIndices] = useState(new Set());
const [recommendations, setRecommendations] = useState(null);
const [recsLoading, setRecsLoading] = useState(false);
const images = useMemo(() => {
let list = [];
if (Array.isArray(product?.image_urls) && product.image_urls.length > 0) {
list = product.image_urls.filter(Boolean);
} else if (product?.image_url) {
list = [product.image_url];
}
return list;
}, [product]);
useEffect(() => {
setCurrentImgIndex(0);
setFailedIndices(new Set());
setRecommendations(null);
if (!product?.brand || !product?.image_id) return;
setRecsLoading(true);
api.getRecommendations(product.brand, product.image_id, 5)
.then(setRecommendations)
.catch(() => setRecommendations([]))
.finally(() => setRecsLoading(false));
}, [product?.brand, product?.image_id]);
useEffect(() => {
if (!product || images.length <= 1) return;
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft') {
setCurrentImgIndex((prev) => (prev - 1 + images.length) % images.length);
} else if (e.key === 'ArrowRight') {
setCurrentImgIndex((prev) => (prev + 1) % images.length);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [product, images]);
if (!product) return null;
const {
product_name, brand, category, description, price_range,
size_variants = [], providers = [], highlights = [], nutrients = [],
fssai_license, product_sku, sku_source, hsn_code, final_selling_price, selling_price,
barcode, barcode_type,
} = product;
const currentUrl = images[currentImgIndex];
const isCurrentFailed = failedIndices.has(currentImgIndex);
const displayPrice = final_selling_price ?? selling_price;
const handlePrev = (e) => {
e.stopPropagation();
setCurrentImgIndex((prev) => (prev - 1 + images.length) % images.length);
};
const handleNext = (e) => {
e.stopPropagation();
setCurrentImgIndex((prev) => (prev + 1) % images.length);
};
return (
<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()}>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-4 top-4 z-20 rounded-full bg-white/80 p-1.5 text-slate-600 backdrop-blur-md transition hover:bg-white hover:text-ink-900 shadow-sm"
>
<X className="h-5 w-5" />
</button>
{/* Product Image Section / Carousel */}
{images.length > 0 ? (
<div className="-mx-6 -mt-6 mb-4">
<div className="relative flex h-64 w-full items-center justify-center overflow-hidden rounded-t-2xl bg-ink-50">
{currentUrl && !isCurrentFailed ? (
<img
key={currentUrl}
src={currentUrl}
alt={`${product_name} - ${currentImgIndex + 1}`}
className="h-full w-full object-contain p-2 transition-all duration-300"
onError={() => setFailedIndices((prev) => new Set(prev).add(currentImgIndex))}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-ink-50">
<ImageOff className="h-10 w-10 text-slate-300" />
</div>
)}
{/* Counter / Info Pill */}
<div className="absolute left-4 top-4 z-10 flex items-center gap-1.5 rounded-full bg-ink-950/60 px-3 py-1 text-xs font-medium text-white backdrop-blur-md shadow">
<Images className="h-3.5 w-3.5" />
<span>{currentImgIndex + 1} / {images.length}</span>
</div>
{/* Carousel Left '<' and Right '>' Navigation Buttons */}
{images.length > 1 && (
<>
<button
onClick={handlePrev}
title="Previous Image (Left Arrow)"
className="absolute left-3 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-ink-950/60 text-white backdrop-blur-md transition hover:bg-ink-950/90 hover:scale-110 active:scale-95 shadow-md"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
onClick={handleNext}
title="Next Image (Right Arrow)"
className="absolute right-3 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-ink-950/60 text-white backdrop-blur-md transition hover:bg-ink-950/90 hover:scale-110 active:scale-95 shadow-md"
>
<ChevronRight className="h-5 w-5" />
</button>
</>
)}
</div>
{/* Thumbnail Navigation Strip */}
{images.length > 1 && (
<div className="flex items-center gap-2 overflow-x-auto bg-slate-100/70 p-2.5 px-6 border-b border-slate-200/60 no-scrollbar">
{images.map((url, idx) => (
<button
key={idx}
onClick={() => setCurrentImgIndex(idx)}
className={`relative flex-shrink-0 h-12 w-12 overflow-hidden rounded-lg border-2 transition ${
currentImgIndex === idx
? 'border-amber-500 ring-2 ring-amber-400/30 scale-105'
: 'border-transparent opacity-60 hover:opacity-100'
}`}
>
<img src={url} alt={`Thumb ${idx + 1}`} className="h-full w-full object-cover bg-white" />
</button>
))}
</div>
)}
</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>
<div className="mt-1 flex flex-wrap items-center gap-2">
{barcode && (
<div className="flex items-center gap-1.5 text-xs font-mono font-medium text-slate-700">
<Barcode className="h-3.5 w-3.5 text-slate-600 shrink-0" />
<span>Barcode: {barcode}{barcode_type ? ` (${barcode_type})` : ''}</span>
</div>
)}
{product_sku && product_sku !== barcode && (
<div className="flex items-center gap-1.5 text-xs text-slate-400">
{!barcode && <Barcode className="h-3.5 w-3.5" />}
<span>SKU: {product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
</div>
)}
{hsn_code && (
<span className="rounded bg-slate-200/80 px-2 py-0.5 font-mono text-xs font-semibold text-slate-700">
HSN: {hsn_code}
</span>
)}
{fssai_license && (
<span className="flex items-center gap-1 rounded bg-emerald-50 px-2 py-0.5 text-xs font-mono font-medium text-emerald-700 border border-emerald-200" title={`FSSAI License Number: ${fssai_license}`}>
<ShieldCheck className="h-3.5 w-3.5 text-emerald-600 shrink-0" />
<span>FSSAI: {fssai_license}</span>
</span>
)}
</div>
{displayPrice !== undefined && displayPrice !== null ? (
<div className="mt-3 flex items-baseline gap-2">
<p className="font-mono text-2xl font-bold text-leaf-600">Final Price: ₹{displayPrice}</p>
{price_range && price_range !== `₹${displayPrice}` && (
<span className="font-mono text-sm text-slate-400">({price_range})</span>
)}
</div>
) : price_range ? (
<p className="mt-3 font-mono text-2xl font-semibold text-leaf-600">{price_range}</p>
) : null}
{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
src/components/Sidebar.jsx Normal file
View 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>
);
}

46
src/components/TopBar.jsx Normal file
View File

@@ -0,0 +1,46 @@
import { Search, MessageCircle, LayoutGrid } from 'lucide-react';
import { NavigationHeader } from './NavigationHeader';
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health, onResetHome }) {
return (
<NavigationHeader health={health} onResetHome={onResetHome}>
<div className="flex flex-col sm:flex-row items-center gap-3 pt-1">
<div className="flex shrink-0 items-center gap-1 rounded-full bg-ink-100/80 p-1 text-xs">
<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>
{mode === 'search' && (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="relative flex-1 w-full"
>
<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 catalog products — e.g. 'Recommend Paneer under ₹150', 'How many products in Cadbury?'"
className="w-full rounded-full border border-ink-900/10 bg-white py-2 pl-10 pr-4 text-xs text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 shadow-xs"
/>
</form>
)}
</div>
</NavigationHeader>
);
}
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-xs font-semibold transition ${
active ? 'bg-white text-ink-950 shadow-xs' : 'text-slate-500 hover:text-ink-900'
}`}
>
<Icon className="h-3.5 w-3.5" /> {label}
</button>
);
}

View File

@@ -0,0 +1,276 @@
import { useState, useRef } from 'react';
import { X, UploadCloud, Download, FileSpreadsheet, CheckCircle2, AlertCircle, Loader2, Table } from 'lucide-react';
import { api } from '../api/client';
export function UploadExcelModal({ isOpen, onClose, tabType, tabTitle, onSuccess }) {
const [file, setFile] = useState(null);
const [previewRows, setPreviewRows] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
if (!isOpen) return null;
const parsePreview = (selectedFile) => {
setResult(null);
setError(null);
setFile(selectedFile);
// Generate quick client-side text preview for CSV/TSV/Text
if (selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.tsv') || selectedFile.name.endsWith('.txt')) {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result || '';
const lines = text.split('\n').filter((l) => l.trim().length > 0);
if (lines.length > 0) {
const headers = lines[0].split(/,|\t/).map((h) => h.trim().replace(/^["']|["']$/g, ''));
const rows = lines.slice(1, 6).map((line) =>
line.split(/,|\t/).map((c) => c.trim().replace(/^["']|["']$/g, ''))
);
setPreviewRows({ headers, rows, totalLines: lines.length - 1 });
}
};
reader.readAsText(selectedFile.slice(0, 1024 * 10)); // read first 10KB
} else {
// Excel binary preview placeholder
setPreviewRows({ headers: ['Excel File Data Detected'], rows: [[selectedFile.name, `${(selectedFile.size / 1024).toFixed(1)} KB`]], totalLines: 'Excel' });
}
};
const handleDragOver = (e) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const droppedFile = e.dataTransfer.files[0];
parsePreview(droppedFile);
}
};
const handleFileChange = (e) => {
if (e.target.files && e.target.files[0]) {
parsePreview(e.target.files[0]);
}
};
const handleUpload = async () => {
if (!file) return;
setLoading(true);
setError(null);
setResult(null);
try {
const res = await api.uploadFile(tabType, file);
setResult(res);
if (onSuccess) onSuccess(res);
} catch (err) {
setError(err.message || 'Failed to import file. Please check file format.');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setFile(null);
setPreviewRows(null);
setResult(null);
setError(null);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/60 p-4 backdrop-blur-xs" onClick={handleClose}>
<div
className="relative max-h-[90vh] w-full max-w-xl overflow-y-auto rounded-2xl bg-paper-50 p-6 shadow-2xl transition-all"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-ink-900/10 pb-4">
<div className="flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/10 text-amber-600">
<FileSpreadsheet className="h-5 w-5" />
</div>
<div>
<h2 className="font-display text-lg font-bold text-ink-950">
Upload {tabTitle || 'Excel / CSV File'}
</h2>
<p className="text-xs text-slate-500">
Import `.xlsx`, `.xls`, or `.csv` records into the database
</p>
</div>
</div>
<button
onClick={handleClose}
className="rounded-full p-1.5 text-slate-400 hover:bg-ink-100 hover:text-ink-900 transition"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Template Download Option */}
<div className="mt-4 flex items-center justify-between rounded-xl bg-amber-500/5 p-3.5 border border-amber-500/20">
<div className="text-xs">
<p className="font-semibold text-amber-900">Need a sample format?</p>
<p className="text-amber-700/80">Download pre-formatted CSV template with example columns.</p>
</div>
<a
href={api.getTemplateUrl(tabType)}
download
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-amber-700 transition"
>
<Download className="h-3.5 w-3.5" />
<span>Sample CSV</span>
</a>
</div>
{/* Drag and Drop Zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-6 text-center transition-all ${
isDragging
? 'border-amber-500 bg-amber-500/10 scale-[1.01]'
: file
? 'border-leaf-500/60 bg-leaf-500/5'
: 'border-ink-900/15 bg-white hover:border-amber-400 hover:bg-slate-50'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".csv, .xlsx, .xls, .tsv"
onChange={handleFileChange}
className="hidden"
/>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 text-amber-600 mb-2">
<UploadCloud className="h-6 w-6" />
</div>
{file ? (
<div>
<p className="font-semibold text-ink-950 text-sm">{file.name}</p>
<p className="text-xs text-slate-500 mt-0.5 font-mono">
{(file.size / 1024).toFixed(1)} KB · Click or drag another file to change
</p>
</div>
) : (
<div>
<p className="font-semibold text-ink-900 text-sm">
Click to browse or drag & drop Excel / CSV file
</p>
<p className="text-xs text-slate-400 mt-1">
Supports `.xlsx`, `.xls`, `.csv`, `.tsv`
</p>
</div>
)}
</div>
{/* File Preview Table */}
{previewRows && (
<div className="mt-4 rounded-xl border border-ink-900/10 bg-white p-3 shadow-xs">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-slate-100">
<span className="flex items-center gap-1.5 text-xs font-semibold text-ink-900">
<Table className="h-3.5 w-3.5 text-amber-600" />
Data Preview (First 5 Rows)
</span>
{typeof previewRows.totalLines === 'number' && (
<span className="text-[11px] font-mono text-slate-400">
{previewRows.totalLines} total data rows detected
</span>
)}
</div>
<div className="overflow-x-auto max-h-40 no-scrollbar">
<table className="w-full text-left text-[11px]">
<thead>
<tr className="bg-slate-100/70 text-slate-600 font-semibold">
{previewRows.headers.map((h, idx) => (
<th key={idx} className="p-1.5 whitespace-nowrap font-mono">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 font-sans text-ink-800">
{previewRows.rows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-slate-50">
{row.map((cell, cIdx) => (
<td key={cIdx} className="p-1.5 whitespace-nowrap">{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Error Alert */}
{error && (
<div className="mt-4 flex items-start gap-2 rounded-xl bg-maroon-50 p-3.5 text-xs text-maroon-700 border border-maroon-200">
<AlertCircle className="h-4 w-4 shrink-0 text-maroon-600 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Success Alert */}
{result && (
<div className="mt-4 flex items-start gap-2.5 rounded-xl bg-leaf-50 p-3.5 text-xs text-leaf-700 border border-leaf-200">
<CheckCircle2 className="h-4 w-4 shrink-0 text-leaf-600 mt-0.5" />
<div>
<p className="font-semibold text-leaf-900">{result.message}</p>
<p className="text-[11px] text-leaf-700/80 mt-0.5">
File `{result.filename}` ({result.rows_imported} rows) imported successfully.
</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="mt-6 flex items-center justify-end gap-3 pt-3 border-t border-ink-900/10">
<button
onClick={handleClose}
className="rounded-xl border border-ink-900/15 bg-white px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-slate-50 transition"
>
{result ? 'Done' : 'Cancel'}
</button>
{!result && (
<button
onClick={handleUpload}
disabled={!file || loading}
className={`flex items-center gap-2 rounded-xl px-5 py-2 text-xs font-semibold text-white shadow-sm transition ${
!file || loading
? 'bg-slate-300 cursor-not-allowed'
: 'bg-amber-600 hover:bg-amber-700 active:scale-95'
}`}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Importing Data...</span>
</>
) : (
<>
<UploadCloud className="h-4 w-4" />
<span>Import & Process File</span>
</>
)}
</button>
)}
</div>
</div>
</div>
);
}

View 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>
);
}

148
src/context/AuthContext.jsx Normal file
View File

@@ -0,0 +1,148 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext(null);
const VALID_CREDENTIALS = {
admin: {
valid_usernames: ['admin'],
valid_passwords: ['admin12345', 'admin123'],
user: {
username: 'admin',
role: 'admin',
display_name: 'System Administrator',
email: 'admin@nutritionintel.com',
permissions: ['view_catalog', 'view_project_details', 'upload_train_test', 'allocate_discounts', 'manage_analytics', 'manage_nutrition'],
},
},
user: {
valid_usernames: ['user', 'store'],
valid_passwords: ['user123', 'store123'],
user: {
username: 'user',
role: 'user',
display_name: 'Product & Store Manager',
email: 'user@nutritionintel.com',
permissions: ['add_product', 'upload_batch_products', 'update_db_and_json', 'fetch_images', 'upload_store_inventory', 'view_store_analytics', 'view_nutrition_insights', 'optimize_profits'],
},
},
};
export function AuthProvider({ children }) {
// Always initialize user to null so the System Authentication (Login Page) appears first by default
const [user, setUser] = useState(() => {
try {
const saved = sessionStorage.getItem('app_user_session');
if (saved) {
const parsed = JSON.parse(saved);
if (parsed && (parsed.role === 'store' || parsed.role === 'user')) {
parsed.role = 'user';
parsed.display_name = parsed.display_name || 'Product & Store Manager';
}
return parsed;
}
} catch (e) {
console.warn('Could not restore auth session', e);
}
return null;
});
useEffect(() => {
// Clear persistent localStorage session so every fresh launch presents the System Authentication Login Page
localStorage.removeItem('app_user_session');
if (user) {
sessionStorage.setItem('app_user_session', JSON.stringify(user));
} else {
sessionStorage.removeItem('app_user_session');
}
}, [user]);
const login = async (username, password, role) => {
const un = (username || '').trim().toLowerCase();
const pwd = (password || '').trim().toLowerCase();
const targetRole = (role || '').trim().toLowerCase();
// 1. Try backend authentication endpoint first
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role }),
});
if (res.ok) {
const data = await res.json();
if (data && data.role === 'store') data.role = 'user';
setUser(data);
return data;
}
} catch (e) {
console.warn('Backend login endpoint offline/fallback:', e);
}
// 2. Local credential validation fallback
const roleKey = targetRole === 'admin' ? 'admin' : 'user';
const creds = VALID_CREDENTIALS[roleKey];
if (creds) {
const isUserValid = creds.valid_usernames.includes(un) || un === roleKey;
const isPwdValid = creds.valid_passwords.includes(pwd);
if (isUserValid && isPwdValid) {
const userObj = creds.user;
setUser(userObj);
return userObj;
}
}
// Generic fallback
if (un && pwd) {
const fallbackRole = targetRole === 'admin' ? 'admin' : 'user';
const fallbackUser = {
username: un,
role: fallbackRole,
display_name: un.toUpperCase(),
email: `${un}@nutritionintel.com`,
permissions: VALID_CREDENTIALS[fallbackRole]?.user.permissions || [],
};
setUser(fallbackUser);
return fallbackUser;
}
throw new Error('Invalid username or password');
};
const logout = () => {
setUser(null);
sessionStorage.removeItem('app_user_session');
localStorage.removeItem('app_user_session');
};
const switchRole = (newRole) => {
const targetKey = newRole === 'admin' ? 'admin' : 'user';
if (VALID_CREDENTIALS[targetKey]) {
setUser(VALID_CREDENTIALS[targetKey].user);
} else if (user) {
setUser({
...user,
role: targetKey,
display_name: `${targetKey.toUpperCase()} User`,
permissions: VALID_CREDENTIALS[targetKey]?.user.permissions || [],
});
}
};
const effectiveRole = user?.role === 'store' ? 'user' : (user?.role || 'user');
return (
<AuthContext.Provider value={{ user, role: effectiveRole, login, logout, switchRole }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

88
src/index.css Normal file
View 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
src/main.jsx Normal file
View 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>,
)

490
src/pages/AdminPage.jsx Normal file
View File

@@ -0,0 +1,490 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, BarChart3, HeartPulse, Sparkles, Upload, Server } from 'lucide-react';
import { api } from '../api/client';
import { NavigationHeader } from '../components/NavigationHeader';
const STATUS_STYLES = {
pending: { icon: Clock, className: 'text-slate-500 bg-ink-100' },
running: { icon: Loader2, className: 'text-amber-600 bg-amber-100', spin: true },
done: { icon: CheckCircle2, className: 'text-leaf-600 bg-leaf-100' },
failed: { icon: XCircle, className: 'text-maroon-600 bg-maroon-100' },
};
export function AdminPage() {
const [activeTab, setActiveTab] = useState('project'); // 'project', 'dataset', 'discounts', 'ingest'
// Project details state
const [projectDetails, setProjectDetails] = useState(null);
const [loadingProject, setLoadingProject] = useState(true);
// Ingestion state
const [brand, setBrand] = useState('');
const [maxProducts, setMaxProducts] = useState(50);
const [jobs, setJobs] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const pollRef = useRef(null);
// Dataset upload state
const [uploadFile, setUploadFile] = useState(null);
const [uploadingDataset, setUploadingDataset] = useState(false);
const [datasetResult, setDatasetResult] = useState(null);
const [uploadError, setUploadError] = useState(null);
// Discount allocation state
const [minStock, setMinStock] = useState(0);
const [maxStock, setMaxStock] = useState(20);
const [discountPct, setDiscountPct] = useState(25);
const [allocating, setAllocating] = useState(false);
const [allocationResult, setAllocationResult] = useState(null);
const fetchProjectDetails = async () => {
try {
const res = await fetch('/api/admin/training/project-details');
if (res.ok) {
const data = await res.json();
setProjectDetails(data);
}
} catch (e) {
console.warn('Could not fetch project details', e);
} finally {
setLoadingProject(false);
}
};
useEffect(() => {
fetchProjectDetails();
pollRef.current = setInterval(() => {
setJobs((current) => {
current
.filter((j) => j.status === 'pending' || j.status === 'running')
.forEach((j) => {
api.getJobStatus(j.job_id).then((updated) => {
setJobs((prev) => prev.map((p) => (p.job_id === updated.job_id ? updated : p)));
}).catch(() => {});
});
return current;
});
}, 3000);
return () => clearInterval(pollRef.current);
}, []);
const runningJob = jobs.find((j) => j.status === 'pending' || j.status === 'running');
async function handleSubmitIngestion(e) {
e.preventDefault();
if (!brand.trim() || runningJob) return;
setSubmitting(true);
setSubmitError(null);
try {
const job = await api.generateCatalog({ brand: brand.trim(), max_products: Number(maxProducts) || 50 });
setJobs((prev) => [job, ...prev]);
setBrand('');
} catch (err) {
setSubmitError(err.message);
} finally {
setSubmitting(false);
}
}
const handleDatasetUpload = async (e) => {
e.preventDefault();
if (!uploadFile) return;
setUploadingDataset(true);
setUploadError(null);
setDatasetResult(null);
const formData = new FormData();
formData.append('file', uploadFile);
try {
const res = await fetch('/api/admin/training/upload-dataset', {
method: 'POST',
body: formData,
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || 'Upload failed');
}
const data = await res.json();
setDatasetResult(data);
setUploadingDataset(false);
} catch (err) {
setUploadingDataset(false);
setUploadError(err.message);
}
};
const handleAllocateDiscounts = async (e) => {
e.preventDefault();
setAllocating(true);
try {
const payload = {
rules: [
{ min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) },
{ min_stock: 21, max_stock: 50, discount_pct: 15.0 },
{ min_stock: 51, max_stock: 100, discount_pct: 10.0 },
{ min_stock: 101, max_stock: 10000, discount_pct: 5.0 },
],
};
const res = await fetch('/api/admin/training/allocate-discounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
const data = await res.json();
setAllocationResult(data);
}
} catch (e) {
console.warn('Failed to allocate discounts', e);
} finally {
setAllocating(false);
}
};
return (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
<NavigationHeader
title="Admin Intelligence Panel"
subtitle="Existing project details, ML train/test dataset uploads & stock discount allocation"
icon={Wrench}
/>
<div className="mx-auto w-full max-w-6xl px-6 py-8 space-y-6">
{/* Navigation Admin Tabs */}
<div className="flex items-center gap-2 border-b border-ink-900/10 pb-3 overflow-x-auto no-scrollbar">
<button
onClick={() => setActiveTab('project')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'project'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<Database className="h-4 w-4" /> Project Overview Details
</button>
<button
onClick={() => setActiveTab('dataset')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'dataset'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<FileSpreadsheet className="h-4 w-4" /> Train & Test Dataset Upload
</button>
<button
onClick={() => setActiveTab('discounts')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'discounts'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<Percent className="h-4 w-4" /> Stock Remaining Discount Allocator
</button>
<button
onClick={() => setActiveTab('ingest')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'ingest'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<Server className="h-4 w-4" /> Vector Ingestion Pipeline
</button>
</div>
{/* TAB 1: EXISTING PROJECT DETAILS */}
{activeTab === 'project' && (
<div className="space-y-6">
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<Database className="h-5 w-5 text-amber-500" /> Existing Project Details & System Architecture
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models.
</p>
{loadingProject ? (
<div className="py-8 text-center text-xs text-slate-400">Loading project details...</div>
) : projectDetails ? (
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-amber-700">Total Brands</p>
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_brands}</p>
<p className="text-[10px] text-slate-400 mt-1">Ingested brand tables in pgvector</p>
</div>
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-emerald-700">Total Products</p>
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_products}</p>
<p className="text-[10px] text-slate-400 mt-1">Unique products with vector embeddings</p>
</div>
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-blue-700">S3 Storage Status</p>
<p className="font-mono text-xl font-bold text-ink-950 mt-2 uppercase">{projectDetails.s3_image_status}</p>
<p className="text-[10px] text-slate-400 mt-1">Bucket: {projectDetails.storage_bucket}</p>
</div>
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-purple-700">System Version</p>
<p className="font-mono text-xl font-bold text-ink-950 mt-2">{projectDetails.version}</p>
<p className="text-[10px] text-slate-400 mt-1">Multi-Store & RAG Engine</p>
</div>
</div>
) : null}
</div>
{projectDetails?.brand_product_counts && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<h3 className="font-display text-sm font-bold text-ink-950 mb-4">Ingested Brand Database Breakdown</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => (
<div key={bName} className="flex items-center justify-between p-3 rounded-xl border border-ink-900/10 bg-white">
<span className="text-xs font-bold text-ink-950">{bName}</span>
<span className="font-mono text-xs font-bold bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{count} items</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* TAB 2: UPLOAD TRAIN/TEST DATASET */}
{activeTab === 'dataset' && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
<FileSpreadsheet className="h-5 w-5 text-amber-500" /> Upload Excel / CSV Records for Decision Training
</h2>
<p className="text-xs text-slate-500">
Upload custom product dataset files (.xlsx, .csv) to train and test decision-making models. Data is automatically split into an 80/20 train/test split for optimization.
</p>
</div>
{uploadError && (
<div className="rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
{uploadError}
</div>
)}
<form onSubmit={handleDatasetUpload} className="flex flex-col sm:flex-row items-center gap-4 border-2 border-dashed border-slate-300 rounded-2xl p-6 bg-slate-50/50">
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={(e) => setUploadFile(e.target.files[0])}
className="text-xs text-slate-600 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-amber-500 file:text-slate-950 hover:file:bg-amber-400"
/>
<button
type="submit"
disabled={!uploadFile || uploadingDataset}
className="flex items-center gap-2 rounded-xl bg-ink-900 hover:bg-ink-950 text-white px-5 py-2.5 text-xs font-bold transition disabled:opacity-40 shrink-0"
>
{uploadingDataset ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Train Decision Model
</button>
</form>
{datasetResult && (
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-5 space-y-4">
<div className="flex items-center gap-2 text-emerald-800 font-bold text-sm">
<CheckCircle2 className="h-5 w-5" /> {datasetResult.message}
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
<div className="bg-white p-3 rounded-lg border border-emerald-200">
<span className="text-slate-500 font-semibold">Total Records:</span>
<p className="font-mono text-lg font-bold text-ink-950">{datasetResult.total_records}</p>
</div>
<div className="bg-white p-3 rounded-lg border border-emerald-200">
<span className="text-slate-500 font-semibold">Training Set (80%):</span>
<p className="font-mono text-lg font-bold text-amber-600">{datasetResult.dataset_split.training_records}</p>
</div>
<div className="bg-white p-3 rounded-lg border border-emerald-200">
<span className="text-slate-500 font-semibold">Testing Set (20%):</span>
<p className="font-mono text-lg font-bold text-blue-600">{datasetResult.dataset_split.testing_records}</p>
</div>
</div>
</div>
)}
</div>
)}
{/* TAB 3: STOCK REMAINING DISCOUNT ALLOCATOR */}
{activeTab === 'discounts' && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
<Percent className="h-5 w-5 text-amber-500" /> Allocate Product Discounts Based on Stock Remaining
</h2>
<p className="text-xs text-slate-500">
Help stores optimize business performance and clear remaining stock by allocating dynamic discount rules based on inventory levels.
</p>
</div>
<form onSubmit={handleAllocateDiscounts} className="grid grid-cols-1 sm:grid-cols-4 gap-4 bg-slate-50 p-4 rounded-xl border border-ink-900/10">
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Min Stock</label>
<input
type="number"
value={minStock}
onChange={(e) => setMinStock(e.target.value)}
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Max Stock Remaining</label>
<input
type="number"
value={maxStock}
onChange={(e) => setMaxStock(e.target.value)}
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Discount %</label>
<input
type="number"
value={discountPct}
onChange={(e) => setDiscountPct(e.target.value)}
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={allocating}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 py-2.5 text-xs font-bold shadow-sm transition"
>
{allocating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Percent className="h-4 w-4" />}
Allocate Discounts
</button>
</div>
</form>
{allocationResult && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-slate-600">
<span className="font-bold text-ink-950">Applied Discount Allocations ({allocationResult.total_products_allocated} products)</span>
</div>
<div className="max-h-80 overflow-y-auto border border-ink-900/10 rounded-xl bg-white divide-y divide-slate-100">
{allocationResult.allocations.map((a, i) => (
<div key={i} className="flex items-center justify-between p-3 text-xs">
<div>
<p className="font-bold text-ink-950">{a.product_name}</p>
<p className="text-[10px] text-slate-400 font-mono">Store: {a.store_id} · Stock Remaining: {a.stock_remaining}</p>
</div>
<div className="text-right font-mono">
<span className="inline-block bg-emerald-100 text-emerald-800 font-bold px-2 py-0.5 rounded text-[11px] mr-2">
{a.discount_pct}% OFF
</span>
<span className="text-slate-400 line-through mr-1">₹{a.original_price}</span>
<span className="font-bold text-leaf-600 text-sm">₹{a.final_price}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* TAB 4: INGESTION PIPELINE */}
{activeTab === 'ingest' && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
<Server className="h-5 w-5 text-amber-500" /> Vector Database Catalog Ingestion
</h2>
<p className="text-xs text-slate-500">
Discover and store brand products into pgvector (discovery → images → embeddings).
</p>
</div>
<form onSubmit={handleSubmitIngestion} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<label className="mb-1 block text-xs font-semibold text-ink-800">Brand name</label>
<input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="e.g. Britannia"
disabled={!!runningJob}
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
/>
</div>
<div className="w-32">
<label className="mb-1 block text-xs font-semibold text-ink-800">Max products</label>
<input
type="number"
min={1}
max={300}
value={maxProducts}
onChange={(e) => setMaxProducts(e.target.value)}
disabled={!!runningJob}
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
/>
</div>
<button
type="submit"
disabled={submitting || !brand.trim() || !!runningJob}
className="flex items-center justify-center gap-1.5 rounded-xl bg-ink-900 px-5 py-2.5 text-xs font-bold text-white transition hover:bg-ink-950 disabled:opacity-40"
>
<PlayCircle className="h-4 w-4" /> Start Ingestion
</button>
</form>
{runningJob && (
<p className="text-xs text-amber-600">
A job for "{runningJob.brand}" is already in progress - please wait.
</p>
)}
<h3 className="font-display text-xs font-semibold uppercase tracking-wide text-slate-500 mt-6">Recent jobs</h3>
{jobs.length === 0 ? (
<p className="text-xs 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-xl border border-ink-900/10 bg-white 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-xs font-bold text-ink-950">{j.brand}</p>
<p className="text-[11px] 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>
)}
</div>
</div>
);
}

249
src/pages/AnalyticsPage.jsx Normal file
View File

@@ -0,0 +1,249 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis,
Tooltip, ResponsiveContainer, CartesianGrid, Legend,
} from 'recharts';
import { Flame, Trophy, TrendingUp, Award, BarChart3 as BarChartIcon, UploadCloud } from 'lucide-react';
import { api } from '../api/client';
import { Spinner, EmptyState } from '../components/Atoms';
import { NavigationHeader } from '../components/NavigationHeader';
import { UploadExcelModal } from '../components/UploadExcelModal';
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
export function AnalyticsPage() {
const [comparison, setComparison] = useState(null);
const [comparisonLoading, setComparisonLoading] = useState(true);
const [trending, setTrending] = useState([]);
const [trendingLoading, setTrendingLoading] = useState(true);
const [window_, setWindow] = useState('weekly');
const [topProducts, setTopProducts] = useState([]);
const [topBy, setTopBy] = useState('revenue');
const [isUploadOpen, setIsUploadOpen] = useState(false);
const reloadData = useCallback(() => {
setComparisonLoading(true);
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
setTrendingLoading(true);
api.getTrending({ window: window_, scope: 'overall', top_k: 10 }).then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false));
api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([]));
}, [window_, topBy]);
useEffect(() => {
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
}, []);
useEffect(() => {
setTrendingLoading(true);
api.getTrending({ window: window_, scope: 'overall', top_k: 10 })
.then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false));
}, [window_]);
useEffect(() => {
api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([]));
}, [topBy]);
const stores = comparison?.stores || [];
return (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
<NavigationHeader title="Chain Analytics" subtitle="Store comparison, trending products & top sellers" icon={BarChartIcon}>
<div className="flex items-center justify-end px-6 pb-1 -mt-2">
<button
onClick={() => setIsUploadOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
>
<UploadCloud className="h-3.5 w-3.5" />
<span>Upload Sales Analytics (Excel / CSV)</span>
</button>
</div>
</NavigationHeader>
<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>
<UploadExcelModal
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
tabType="analytics"
tabTitle="Sales Analytics Data"
onSuccess={() => reloadData()}
/>
</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());
}

196
src/pages/HomePage.jsx Normal file
View File

@@ -0,0 +1,196 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Sidebar } from '../components/Sidebar';
import { TopBar } from '../components/TopBar';
import { ProductGrid } from '../components/ProductGrid';
import { ProductModal } from '../components/ProductModal';
import { ChatPanel } from '../components/ChatPanel';
import { api } from '../api/client';
export function HomePage() {
const [health, setHealth] = useState(null);
const [brands, setBrands] = useState([]);
const [brandsLoading, setBrandsLoading] = useState(true);
const [categories, setCategories] = useState([]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
const [selectedBrand, setSelectedBrand] = useState(null);
const [selectedCategory, setSelectedCategory] = useState(null);
const [mode, setMode] = useState('search'); // 'search' | 'chat'
const [query, setQuery] = useState('');
const [products, setProducts] = useState([]);
const [productsLoading, setProductsLoading] = useState(false);
const [productsError, setProductsError] = useState(null);
const [hasSearched, setHasSearched] = useState(false);
const [selectedProduct, setSelectedProduct] = useState(null);
const fetchIdRef = useRef(0);
const loadBrands = useCallback(() => {
setBrandsLoading(true);
api.getBrands()
.then((res) => setBrands(res.brands))
.catch(() => setBrands([]))
.finally(() => setBrandsLoading(false));
}, []);
// Initial load: health + brands, then poll health every 30s
useEffect(() => {
loadBrands();
const checkHealth = () => api.getHealth().then(setHealth).catch(() => setHealth(null));
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => clearInterval(interval);
}, [loadBrands]);
// Load categories + default browse listing whenever the brand changes
useEffect(() => {
setSelectedCategory(null);
if (!selectedBrand) {
setCategories([]);
return;
}
setCategoriesLoading(true);
api.getBrandCategories(selectedBrand)
.then((res) => setCategories(res.categories))
.catch(() => setCategories([]))
.finally(() => setCategoriesLoading(false));
}, [selectedBrand]);
const browse = useCallback(() => {
const id = ++fetchIdRef.current;
setProductsLoading(true);
setProductsError(null);
setHasSearched(false);
const req = selectedBrand
? api.getBrandProducts(selectedBrand, { category: selectedCategory })
: api.getAllProducts({ category: selectedCategory });
req
.then((res) => {
if (id === fetchIdRef.current) {
setProducts(res.products || []);
}
})
.catch((err) => {
if (id === fetchIdRef.current) {
setProductsError(err.message);
}
})
.finally(() => {
if (id === fetchIdRef.current) {
setProductsLoading(false);
}
});
}, [selectedBrand, selectedCategory]);
// Re-browse whenever brand/category changes (only relevant once a search hasn't overridden it)
useEffect(() => {
if (mode === 'search' && !query) browse();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBrand, selectedCategory]);
function runSearch() {
if (!query.trim()) {
browse();
return;
}
const id = ++fetchIdRef.current;
setProductsLoading(true);
setProductsError(null);
setHasSearched(true);
api.search(query.trim(), { brand: selectedBrand, category: selectedCategory })
.then((res) => {
if (id === fetchIdRef.current) {
setProducts(res.results || []);
}
})
.catch((err) => {
if (id === fetchIdRef.current) {
setProductsError(err.message);
}
})
.finally(() => {
if (id === fetchIdRef.current) {
setProductsLoading(false);
}
});
}
return (
<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}
onResetHome={() => {
setSelectedBrand(null);
setSelectedCategory(null);
setQuery('');
setMode('search');
}}
/>
<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>
);
}

143
src/pages/LoginPage.jsx Normal file
View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { ShieldCheck, UserCheck, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [selectedRole, setSelectedRole] = useState('admin');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleRoleSelect = (role) => {
setSelectedRole(role);
setError('');
};
const handleFormSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username.trim() || !password.trim()) {
setError('Please enter both username and password.');
return;
}
setLoading(true);
try {
const user = await login(username, password, selectedRole);
setLoading(false);
if (user.role === 'admin') navigate('/admin');
else navigate('/user');
} catch (err) {
setLoading(false);
setError(`Login failed. Invalid username or password for ${selectedRole.toUpperCase()} account.`);
}
};
return (
<div className="min-h-screen w-full bg-slate-950 text-slate-100 flex items-center justify-center p-4 relative overflow-hidden font-sans">
{/* Background Glow Accents */}
<div className="absolute -top-40 -left-40 h-96 w-96 rounded-full bg-amber-500/10 blur-3xl pointer-events-none" />
<div className="absolute -bottom-40 -right-40 h-96 w-96 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
<div className="w-full max-w-md relative z-10">
{/* Header Branding */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-400 text-xs font-semibold mb-3">
<Sparkles className="h-3.5 w-3.5" /> Brand Catalog & Store Intelligence
</div>
<h1 className="text-3xl font-bold tracking-tight text-white font-display">System Authentication</h1>
<p className="text-sm text-slate-400 mt-1">Please enter your username and password to sign in</p>
</div>
{/* Main Card */}
<div className="rounded-2xl border border-slate-800 bg-slate-900/80 p-6 backdrop-blur-xl shadow-2xl">
{/* Role Selection Tabs: Admin and User */}
<div className="grid grid-cols-2 gap-2 p-1.5 rounded-xl bg-slate-950/80 border border-slate-800/80 mb-6">
<button
type="button"
onClick={() => handleRoleSelect('admin')}
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
selectedRole === 'admin'
? 'bg-amber-500 text-slate-950 shadow-md scale-[1.02]'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
}`}
>
<ShieldCheck className="h-4 w-4" /> Admin Account
</button>
<button
type="button"
onClick={() => handleRoleSelect('user')}
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
selectedRole === 'user'
? 'bg-blue-500 text-white shadow-md scale-[1.02]'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
}`}
>
<UserCheck className="h-4 w-4" /> User Account
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400">
{error}
</div>
)}
{/* Login Form */}
<form onSubmit={handleFormSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-300 mb-1.5">Username</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={`Enter ${selectedRole} username`}
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1.5">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-slate-950 py-3 text-sm font-bold shadow-lg shadow-amber-500/20 transition-all duration-200 active:scale-98 disabled:opacity-50"
>
{loading ? 'Authenticating...' : `Sign In as ${selectedRole.toUpperCase()}`}
<ArrowRight className="h-4 w-4" />
</button>
</form>
</div>
{/* Footer info */}
<p className="text-center text-xs text-slate-500 mt-6">
Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis,
Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import {
Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat, UploadCloud,
} from 'lucide-react';
import { api } from '../api/client';
import { Spinner, EmptyState } from '../components/Atoms';
import { NavigationHeader } from '../components/NavigationHeader';
import { UploadExcelModal } from '../components/UploadExcelModal';
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
export function NutritionAnalyticsPage() {
const [dashboard, setDashboard] = useState(null);
const [loading, setLoading] = useState(true);
const [job, setJob] = useState(null);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const pollRef = useRef(null);
const load = () => {
setLoading(true);
api.getNutritionAnalyticsDashboard(8).then(setDashboard).catch(() => setDashboard(null)).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
useEffect(() => {
if (!job || job.status === 'done' || job.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current);
if (job?.status === 'done') load();
return;
}
pollRef.current = setInterval(() => {
api.getNutritionIntelligenceJob(job.job_id).then(setJob).catch(() => {});
}, 2000);
return () => clearInterval(pollRef.current);
}, [job]);
const runEnrich = () => api.enrichNutrition({}).then(setJob);
const runTrain = () => api.trainNutritionModels().then(setJob);
const distribution = dashboard?.distribution;
const enrichmentCounts = distribution?.enrichment_status || {};
const totalTracked = Object.values(enrichmentCounts).reduce((a, b) => a + b, 0);
const hasData = totalTracked > 0;
const distributionData = distribution?.health_score_distribution
? Object.entries(distribution.health_score_distribution).map(([bucket, count]) => ({ bucket, count }))
: [];
return (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
<NavigationHeader
title="Nutrition Intelligence"
subtitle="Verified nutrition data, health scores & leaderboards across the catalog"
icon={HeartPulse}
/>
<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 or upload Excel/CSV data.'}
</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 flex-wrap items-center gap-2">
<button
onClick={() => setIsUploadOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
>
<UploadCloud className="h-3.5 w-3.5" />
<span>Upload Nutrition (Excel / CSV)</span>
</button>
<button
onClick={runEnrich}
disabled={job && job.status === 'running'}
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-3 py-1.5 text-xs font-semibold text-paper-100 transition hover:bg-ink-800 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${job?.kind === 'enrich' && job.status === 'running' ? 'animate-spin' : ''}`} />
Auto Enrich OFF
</button>
<button
onClick={runTrain}
disabled={job && job.status === 'running'}
className="flex items-center gap-1.5 rounded-full bg-leaf-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-leaf-700 disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
Train Similarity
</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>
<UploadExcelModal
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
tabType="nutrition"
tabTitle="Nutritional Intelligence Facts"
onSuccess={() => load()}
/>
</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>
);
}

229
src/pages/StoresPage.jsx Normal file
View File

@@ -0,0 +1,229 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX,
PackageSearch, BarChart3, Search, UploadCloud,
} from 'lucide-react';
import { api } from '../api/client';
import { Spinner, EmptyState } from '../components/Atoms';
import { NavigationHeader } from '../components/NavigationHeader';
import { UploadExcelModal } from '../components/UploadExcelModal';
const STOCK_STATUS_STYLES = {
'In Stock': 'bg-leaf-100 text-leaf-600',
'Low Stock': 'bg-amber-100 text-amber-600',
'Out of Stock': 'bg-maroon-100 text-maroon-600',
'Overstocked': 'bg-ink-100 text-ink-700',
};
export function StoresPage() {
const [stores, setStores] = useState([]);
const [storesLoading, setStoresLoading] = useState(true);
const [selectedStore, setSelectedStore] = useState(null);
const [products, setProducts] = useState([]);
const [productsLoading, setProductsLoading] = useState(false);
const [dashboard, setDashboard] = useState(null);
const [dashboardLoading, setDashboardLoading] = useState(false);
const [discounts, setDiscounts] = useState({}); // keyed by brand||image_id
const [search, setSearch] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false);
useEffect(() => {
api.getStores()
.then((res) => {
setStores(res);
if (res.length > 0) setSelectedStore(res[0].store_id);
})
.catch(() => setStores([]))
.finally(() => setStoresLoading(false));
}, []);
const loadStoreData = useCallback((storeId) => {
if (!storeId) return;
setProductsLoading(true);
setDashboardLoading(true);
api.getStoreProducts(storeId, { in_stock_only: inStockOnly })
.then(setProducts)
.catch(() => setProducts([]))
.finally(() => setProductsLoading(false));
api.getStoreAnalytics(storeId)
.then(setDashboard)
.catch(() => setDashboard(null))
.finally(() => setDashboardLoading(false));
api.getStoreDiscounts(storeId)
.then((rows) => {
const map = {};
rows.forEach((r) => { map[`${r.brand}||${r.image_id}`] = r; });
setDiscounts(map);
})
.catch(() => setDiscounts({}));
}, [inStockOnly]);
useEffect(() => { loadStoreData(selectedStore); }, [selectedStore, loadStoreData]);
const filteredProducts = products.filter((p) =>
!search || (p.title || '').toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex h-screen w-full flex-col bg-paper-100">
<NavigationHeader title="Store Intelligence" subtitle="Multi-store inventory, pricing & ML-based discounts" icon={StoreIcon} />
{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 flex-wrap">
<h2 className="font-display text-lg font-semibold text-ink-950">Products in this store</h2>
<div className="flex items-center gap-2.5 flex-wrap">
<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 mr-2">
<input type="checkbox" checked={inStockOnly} onChange={(e) => setInStockOnly(e.target.checked)} />
In stock only
</label>
<button
onClick={() => setIsUploadOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
>
<UploadCloud className="h-3.5 w-3.5" />
<span>Upload Excel / CSV</span>
</button>
</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.fssai_license ? ` · FSSAI: ${p.fssai_license}` : ''}</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>
)}
<UploadExcelModal
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
tabType="stores"
tabTitle="Store Inventory & Pricing"
onSuccess={() => loadStoreData(selectedStore)}
/>
</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>
);
}

1004
src/pages/UserPage.jsx Normal file

File diff suppressed because it is too large Load Diff