Update nutrition score retrieval using OFF v2 REST API and title cleaning
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
117
frontend/src/components/NavigationHeader.jsx
Normal file
117
frontend/src/components/NavigationHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
276
frontend/src/components/UploadExcelModal.jsx
Normal file
276
frontend/src/components/UploadExcelModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user