update userpage catalog

This commit is contained in:
sriram
2026-08-11 17:30:29 +05:30
parent 370f867355
commit 77d8536941
33 changed files with 3561 additions and 236 deletions

View File

@@ -1,21 +1,108 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/nutrition-analytics" element={<NutritionAnalyticsPage />} />
<Route path="/nutrition" element={<NutritionAnalyticsPage />} />
</Routes>
</BrowserRouter>
<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>
);
}

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

@@ -1,5 +1,7 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, Sparkles } from 'lucide-react';
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,
@@ -11,6 +13,29 @@ export function NavigationHeader({
}) {
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">
@@ -18,15 +43,15 @@ export function NavigationHeader({
{/* Left Section: Home/Back Button + Page Title */}
<div className="flex items-center gap-3">
<Link
to="/"
to={role === 'admin' ? '/' : '/user'}
onClick={() => {
if (onResetHome) onResetHome();
}}
title="Navigate to Home Page (All Brands Catalog)"
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>Home / All Brands</span>
<span>{role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'}</span>
</Link>
{title && (
@@ -46,7 +71,7 @@ export function NavigationHeader({
)}
</div>
{/* Right Section: Health Alert + Navigation Tabs */}
{/* 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">
@@ -59,28 +84,60 @@ export function NavigationHeader({
</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="/"
onClick={() => {
if (onResetHome) onResetHome();
}}
to="/user"
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'
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'
}`}
>
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
<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" /> Stores
<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 ${
@@ -90,6 +147,7 @@ export function NavigationHeader({
<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 ${
@@ -99,15 +157,28 @@ export function NavigationHeader({
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
</Link>
<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" /> Admin
</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>

View File

@@ -21,20 +21,42 @@ const FOOD_CATEGORIES = [
];
function isFoodCategory(category) {
if (!category) return false;
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 }) {
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, image_urls = [], nutrients = [], fssai_license, product_sku, sku_source } = product;
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) list.push(image_url);
if (image_url && typeof image_url === 'string') list.push(image_url);
if (Array.isArray(image_urls)) {
for (const u of image_urls) {
if (u && !list.includes(u)) list.push(u);
if (u && typeof u === 'string' && !list.includes(u)) list.push(u);
}
}
return list;
@@ -43,9 +65,10 @@ export function ProductCard({ product, onClick, similarity }) {
const [imgIndex, setImgIndex] = useState(0);
const [imgError, setImgError] = useState(false);
const showNutrients = useMemo(() => isFoodCategory(category) && nutrients.length > 0, [category, nutrients]);
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) {
@@ -58,14 +81,14 @@ export function ProductCard({ product, onClick, similarity }) {
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"
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={product_name}
alt={String(product_name)}
className="h-40 w-full object-contain bg-ink-50 transition duration-300 group-hover:scale-105"
onError={handleImgError}
/>
@@ -84,68 +107,89 @@ export function ProductCard({ product, onClick, similarity }) {
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-display text-[15px] font-semibold leading-snug text-ink-950">{product_name}</p>
<p className="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">
{brand} {category ? `· ${category}` : ''}
{String(brand)} {category ? `· ${String(category)}` : ''}
</p>
{product_sku && (
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-slate-400">
<Barcode className="h-3 w-3" />
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
</div>
)}
<div 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>
{typeof similarity === 'number' && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-mono font-medium text-amber-600">
<Sparkles className="h-3 w-3" /> {Math.round(similarity * 100)}%
{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>
{price_range && (
<p className="font-mono text-lg font-semibold text-leaf-600">{price_range}</p>
)}
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).slice(0, 3).map((h, i) => (
<span
key={i}
className="rounded-full bg-ink-100 px-2 py-0.5 text-[11px] font-medium text-ink-700"
>
{h}
<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>
))}
</div>
)}
{showNutrients && (
<div className="flex flex-wrap items-center gap-1.5">
<Leaf className="h-3 w-3 shrink-0 text-leaf-700" />
{nutrients.map((n, i) => (
<span
key={i}
className="rounded-full bg-leaf-100 px-2 py-0.5 text-[11px] font-medium text-leaf-700"
>
{n.split(' - ')[0]}
) : 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>
)}
<div className="dash-divider mt-auto" />
{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>
)}
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Store className="h-3.5 w-3.5" />
{providers.length > 0 ? providers.join(', ') : 'No listed providers'}
{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>
{fssai_license && (
<div className="flex items-center gap-1.5 text-[11px] text-leaf-700">
<ShieldCheck className="h-3.5 w-3.5" />
<span>FSSAI: {fssai_license}</span>
</div>
)}
</button>
);
}

View File

@@ -1,4 +1,4 @@
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images } from 'lucide-react';
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';
@@ -48,11 +48,13 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
const {
product_name, brand, category, description, price_range,
size_variants = [], providers = [], highlights = [], nutrients = [],
product_sku, sku_source,
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();
@@ -145,16 +147,42 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
{brand} {category ? `· ${category}` : ''}
</p>
<h2 className="mt-1 font-display text-xl font-bold text-ink-950">{product_name}</h2>
{product_sku && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-slate-400">
<Barcode className="h-3.5 w-3.5" />
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
</div>
)}
<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>
{price_range && (
{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>}

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench } from 'lucide-react';
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';
@@ -12,6 +12,13 @@ const STATUS_STYLES = {
};
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([]);
@@ -19,7 +26,36 @@ export function AdminPage() {
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
@@ -37,7 +73,7 @@ export function AdminPage() {
const runningJob = jobs.find((j) => j.status === 'pending' || j.status === 'running');
async function handleSubmit(e) {
async function handleSubmitIngestion(e) {
e.preventDefault();
if (!brand.trim() || runningJob) return;
setSubmitting(true);
@@ -53,85 +89,401 @@ export function AdminPage() {
}
}
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">
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
<NavigationHeader
title="Catalog Ingestion"
subtitle="Discover and index new FMCG brand catalogs into pgvector"
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-2xl px-6 py-8">
<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>
<h1 className="font-display text-2xl font-bold text-ink-950">Catalog ingestion</h1>
<p className="mt-1 text-sm text-slate-500">
Discover and store a new brand's products in pgvector (discovery → images → embeddings).
This calls the local Ollama model repeatedly and can take a few minutes per brand on
CPU-only hardware - avoid running this at the same time as heavy chat traffic.
</p>
<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>
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-3 rounded-xl border border-ink-900/10 bg-paper-50 p-5 sm:flex-row sm:items-end">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Brand name</label>
<input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="e.g. Britannia"
disabled={!!runningJob}
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
/>
<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>
<div className="w-32">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Max products</label>
<input
type="number"
min={1}
max={300}
value={maxProducts}
onChange={(e) => setMaxProducts(e.target.value)}
disabled={!!runningJob}
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
/>
</div>
<button
type="submit"
disabled={submitting || !brand.trim() || !!runningJob}
className="flex items-center justify-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-sm font-medium text-paper-100 transition hover:bg-ink-800 disabled:opacity-40"
>
<PlayCircle className="h-4 w-4" /> Start ingestion
</button>
</form>
{runningJob && (
<p className="mt-2 text-xs text-amber-600">
A job for "{runningJob.brand}" is already in progress - wait for it to finish before starting another.
</p>
)}
{submitError && <p className="mt-2 text-sm text-maroon-600">{submitError}</p>}
{/* 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>
<h2 className="mb-3 mt-8 font-display text-sm font-semibold uppercase tracking-wide text-slate-500">Recent jobs</h2>
{jobs.length === 0 ? (
<p className="text-sm text-slate-500">No ingestion jobs started yet this session.</p>
) : (
<ul className="space-y-2">
{jobs.map((j) => {
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
const Icon = style.icon;
return (
<li key={j.job_id} className="flex items-center gap-3 rounded-lg border border-ink-900/10 bg-paper-50 px-4 py-3">
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
</span>
<div className="flex-1">
<p className="text-sm font-medium text-ink-950">{j.brand}</p>
<p className="text-xs text-slate-500">{j.detail || j.status}</p>
{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>
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
</li>
);
})}
</ul>
)}
) : 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>
);

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

@@ -160,7 +160,7 @@ export function StoresPage() {
<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>
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,13 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
// Avoid CORS entirely in dev by proxying /api/* straight to FastAPI.
// Adjust the target if your backend runs on a different host/port.
// Proxy all /api/* requests straight to FastAPI on port 8000
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
ws: true,
},
},
},
})