Update nutrition score retrieval using OFF v2 REST API and title cleaning

This commit is contained in:
sriram
2026-08-10 18:54:27 +05:30
parent 72dd9f8296
commit 370f867355
43 changed files with 2088 additions and 373 deletions

View File

@@ -14,6 +14,7 @@ export default function App() {
<Route path="/stores" element={<StoresPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/nutrition-analytics" element={<NutritionAnalyticsPage />} />
<Route path="/nutrition" element={<NutritionAnalyticsPage />} />
</Routes>
</BrowserRouter>
);

View File

@@ -157,6 +157,27 @@ export const api = {
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 };

View File

@@ -0,0 +1,117 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, Sparkles } from 'lucide-react';
export function NavigationHeader({
title,
subtitle,
icon: TitleIcon,
health,
onResetHome,
children,
}) {
const location = useLocation();
const path = location.pathname;
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="/"
onClick={() => {
if (onResetHome) onResetHome();
}}
title="Navigate to Home Page (All Brands Catalog)"
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>
</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: Health Alert + Navigation Tabs */}
<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>
)}
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
<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>
<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
</Link>
<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>
<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>
<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>
</nav>
</div>
</div>
{children}
</header>
);
}

View File

@@ -1,4 +1,4 @@
import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode } from 'lucide-react';
import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode, Images } from 'lucide-react';
import { useState, useMemo } from 'react';
const FOOD_CATEGORIES = [
@@ -27,29 +27,60 @@ function isFoodCategory(category) {
}
export function ProductCard({ product, onClick, similarity }) {
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, nutrients = [], fssai_license, product_sku, sku_source } = product;
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, image_urls = [], nutrients = [], fssai_license, product_sku, sku_source } = product;
const allImages = useMemo(() => {
const list = [];
if (image_url) list.push(image_url);
if (Array.isArray(image_urls)) {
for (const u of image_urls) {
if (u && !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) && nutrients.length > 0, [category, nutrients]);
const imageCount = allImages.length;
const currentDisplayUrl = allImages[imgIndex];
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"
>
{image_url && !imgError ? (
<div className="-mx-4 -mt-4 -ml-6 mb-1 overflow-hidden rounded-t-xl">
{currentDisplayUrl && !imgError ? (
<div className="-mx-4 -mt-4 -ml-6 mb-1 relative overflow-hidden rounded-t-xl">
<img
src={image_url}
key={currentDisplayUrl}
src={currentDisplayUrl}
alt={product_name}
className="h-40 w-full object-contain bg-ink-50"
onError={() => setImgError(true)}
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>
) : image_url && imgError ? (
) : (
<div className="-mx-4 -mt-4 -ml-6 mb-1 flex h-40 items-center justify-center rounded-t-xl bg-ink-50">
<ImageOff className="h-8 w-8 text-slate-300" />
</div>
) : null}
)}
<div className="flex items-start justify-between gap-2">
<div>

View File

@@ -1,15 +1,27 @@
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles } from 'lucide-react';
import { useEffect, useState } from 'react';
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images } 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 [imgError, setImgError] = useState(false);
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(() => {
setImgError(false);
setCurrentImgIndex(0);
setFailedIndices(new Set());
setRecommendations(null);
if (!product?.brand || !product?.image_id) return;
setRecsLoading(true);
@@ -19,32 +31,113 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
.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 = [],
image_url, product_sku, sku_source,
product_sku, sku_source,
} = product;
const currentUrl = images[currentImgIndex];
const isCurrentFailed = failedIndices.has(currentImgIndex);
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()}>
<button onClick={onClose} className="absolute right-4 top-4 rounded-full p-1.5 text-slate-500 hover:bg-ink-100 hover:text-ink-900">
{/* 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>
{image_url && !imgError ? (
<div className="-mx-6 -mt-6 mb-4 overflow-hidden rounded-t-2xl">
<img
src={image_url}
alt={product_name}
className="h-56 w-full object-contain bg-ink-50"
onError={() => setImgError(true)}
/>
</div>
) : image_url && imgError ? (
<div className="-mx-6 -mt-6 mb-4 flex h-56 items-center justify-center rounded-t-2xl bg-ink-50">
<ImageOff className="h-10 w-10 text-slate-300" />
{/* 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}

View File

@@ -1,81 +1,34 @@
import { Search, MessageCircle, LayoutGrid, AlertTriangle, Wrench, Store, BarChart3, HeartPulse } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health }) {
const location = useLocation();
import { Search, MessageCircle, LayoutGrid } from 'lucide-react';
import { NavigationHeader } from './NavigationHeader';
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health, onResetHome }) {
return (
<header className="flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 rounded-full bg-ink-100 p-1">
<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>
<div className="flex items-center gap-3">
{health && health.status !== 'ok' && (
<span className="flex items-center gap-1.5 rounded-full bg-maroon-100 px-3 py-1 text-xs font-medium text-maroon-600">
<AlertTriangle className="h-3.5 w-3.5" />
{!health.database && !health.ollama
? 'DB & Ollama unreachable'
: !health.database
? 'Database unreachable'
: 'Ollama unreachable'}
</span>
)}
<Link
to="/stores"
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
location.pathname === '/stores' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
}`}
{mode === 'search' && (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="relative flex-1 w-full"
>
<Store className="h-3.5 w-3.5" /> Stores
</Link>
<Link
to="/analytics"
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
location.pathname === '/analytics' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
}`}
>
<BarChart3 className="h-3.5 w-3.5" /> Analytics
</Link>
<Link
to="/nutrition-analytics"
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
location.pathname === '/nutrition-analytics' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
}`}
>
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
</Link>
<Link
to="/admin"
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
location.pathname === '/admin' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
}`}
>
<Wrench className="h-3.5 w-3.5" /> Admin
</Link>
</div>
<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>
{mode === 'search' && (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="relative"
>
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search products by name, ingredient, or vibe — e.g. 'low sugar biscuit under 20 rupees'"
className="w-full rounded-full border border-ink-900/10 bg-paper-50 py-2.5 pl-10 pr-4 text-sm text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20"
/>
</form>
)}
</header>
</NavigationHeader>
);
}
@@ -83,11 +36,11 @@ function ModeButton({ icon: Icon, label, active, onClick }) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium transition ${
active ? 'bg-paper-50 text-ink-950 shadow-sm' : 'text-slate-500 hover:text-ink-900'
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-4 w-4" /> {label}
<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

@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock } from 'lucide-react';
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench } 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' },
@@ -53,10 +54,14 @@ export function AdminPage() {
}
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<Link to="/" className="mb-6 flex items-center gap-1.5 text-sm text-slate-500 hover:text-ink-900">
<ArrowLeft className="h-4 w-4" /> Back to catalog
</Link>
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
<NavigationHeader
title="Catalog Ingestion"
subtitle="Discover and index new FMCG brand catalogs into pgvector"
icon={Wrench}
/>
<div className="mx-auto w-full max-w-2xl px-6 py-8">
<h1 className="font-display text-2xl font-bold text-ink-950">Catalog ingestion</h1>
<p className="mt-1 text-sm text-slate-500">
@@ -127,6 +132,7 @@ export function AdminPage() {
})}
</ul>
)}
</div>
</div>
);
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
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 } from 'lucide-react';
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'];
@@ -20,6 +22,15 @@ export function AnalyticsPage() {
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));
@@ -39,7 +50,17 @@ export function AnalyticsPage() {
return (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
<PageHeader />
<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 ? (
@@ -189,6 +210,14 @@ export function AnalyticsPage() {
</div>
</div>
</div>
<UploadExcelModal
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
tabType="analytics"
tabTitle="Sales Analytics Data"
onSuccess={() => reloadData()}
/>
</div>
);
}
@@ -218,20 +247,3 @@ function prettify(imageId) {
const parts = imageId.split('_').filter((p, i, arr) => !(i > 0 && arr[i - 1] === p));
return parts.join(' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function PageHeader() {
return (
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
<div>
<h1 className="font-display text-lg font-bold text-ink-950">Analytics</h1>
<p className="text-xs text-slate-400">Store comparison, trending products &amp; top sellers</p>
</div>
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
<Link to="/stores" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Stores</Link>
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Analytics</span>
<Link to="/nutrition-analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Nutrition</Link>
</nav>
</header>
);
}

View File

@@ -142,6 +142,12 @@ export function HomePage() {
onQueryChange={setQuery}
onSubmit={runSearch}
health={health}
onResetHome={() => {
setSelectedBrand(null);
setSelectedCategory(null);
setQuery('');
setMode('search');
}}
/>
<main className="flex-1 overflow-y-auto">

View File

@@ -5,10 +5,12 @@ import {
Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import {
Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat,
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'];
@@ -16,6 +18,7 @@ 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 = () => {
@@ -51,7 +54,11 @@ export function NutritionAnalyticsPage() {
return (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
<PageHeader />
<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">
@@ -60,7 +67,7 @@ export function NutritionAnalyticsPage() {
<p className="text-xs text-slate-400">
{hasData
? `${enrichmentCounts.verified || 0} verified · ${enrichmentCounts.partial || 0} partial · ${enrichmentCounts.unavailable || 0} unavailable of ${totalTracked} tracked products`
: 'No products enriched yet - run enrichment to retrieve verified nutrition data from Open Food Facts.'}
: 'No products enriched yet - run enrichment or upload Excel/CSV data.'}
</p>
{job && (
<p className="mt-1 text-xs text-amber-600">
@@ -70,20 +77,29 @@ export function NutritionAnalyticsPage() {
</p>
)}
</div>
<div className="flex gap-2">
<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 !== 'done' && job.status !== 'failed'}
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-4 py-2 text-xs font-medium text-paper-100 disabled:opacity-40"
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"
>
<Play className="h-3.5 w-3.5" /> Run enrichment
<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 !== 'done' && job.status !== 'failed'}
className="flex items-center gap-1.5 rounded-full border border-ink-900/15 px-4 py-2 text-xs font-medium text-ink-900 disabled:opacity-40"
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"
>
<RefreshCw className="h-3.5 w-3.5" /> Train similarity/clustering
<Play className="h-3.5 w-3.5" />
Train Similarity
</button>
</div>
</div>
@@ -148,6 +164,14 @@ export function NutritionAnalyticsPage() {
</>
)}
</div>
<UploadExcelModal
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
tabType="nutrition"
tabTitle="Nutritional Intelligence Facts"
onSuccess={() => load()}
/>
</div>
);
}
@@ -190,20 +214,3 @@ function Leaderboard({ title, icon: Icon, items = [], field, unit }) {
</div>
);
}
function PageHeader() {
return (
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
<div>
<h1 className="font-display text-lg font-bold text-ink-950">Nutrition Analytics</h1>
<p className="text-xs text-slate-400">Verified nutrition data, health scores &amp; leaderboards across the catalog</p>
</div>
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
<Link to="/stores" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Stores</Link>
<Link to="/analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Analytics</Link>
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Nutrition</span>
</nav>
</header>
);
}

View File

@@ -2,10 +2,12 @@ import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX,
PackageSearch, BarChart3, Search,
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',
@@ -27,6 +29,7 @@ export function StoresPage() {
const [search, setSearch] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false);
useEffect(() => {
api.getStores()
@@ -67,7 +70,7 @@ export function StoresPage() {
return (
<div className="flex h-screen w-full flex-col bg-paper-100">
<PageHeader />
<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>
@@ -104,9 +107,9 @@ export function StoresPage() {
<KpiRow dashboard={dashboard} />
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<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">
<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
@@ -116,10 +119,18 @@ export function StoresPage() {
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">
<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>
@@ -180,6 +191,14 @@ export function StoresPage() {
</div>
</div>
)}
<UploadExcelModal
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
tabType="stores"
tabTitle="Store Inventory & Pricing"
onSuccess={() => loadStoreData(selectedStore)}
/>
</div>
);
}
@@ -208,20 +227,3 @@ function KpiRow({ dashboard }) {
</div>
);
}
function PageHeader() {
return (
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
<div>
<h1 className="font-display text-lg font-bold text-ink-950">Store Intelligence</h1>
<p className="text-xs text-slate-400">Multi-store inventory, pricing &amp; ML-based discounts</p>
</div>
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Stores</span>
<Link to="/analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Analytics</Link>
<Link to="/nutrition-analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Nutrition</Link>
</nav>
</header>
);
}