update backend apis

This commit is contained in:
sriram
2026-08-13 16:03:51 +05:30
parent 282a494aab
commit 586eeecbf5
4 changed files with 214 additions and 29 deletions

View File

@@ -163,6 +163,11 @@ export const api = {
getBrands: () => request('/api/brands'),
// Richer per-brand summaries for the home page card grid. `getBrands` above
// still returns plain names and is what the sidebar and admin pages use.
getBrandCards: ({ refresh = false } = {}) =>
request(`/api/brands/overview${qs({ refresh: refresh || undefined })}`),
getBrandCategories: (brand) => request(`/api/brands/${encodeURIComponent(brand)}/categories`),
getBrandProducts: (brand, { category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>

View File

@@ -0,0 +1,79 @@
import { useState } from 'react';
import { Package, Tag } from 'lucide-react';
export function BrandCard({ brand, onClick }) {
// Hooks run before the null guard, matching ProductCard: React identifies
// hooks by call order, so bailing out above them would change how many run
// between renders if `brand` ever went from set to null.
const [imgError, setImgError] = useState(false);
if (!brand) return null;
const {
name = 'Brand',
product_count = 0,
category_count = 0,
categories = [],
image_url,
initials = '?',
} = brand;
const showImage = Boolean(image_url) && !imgError;
return (
<button
onClick={onClick}
title={name}
className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-ink-900/10 bg-paper-50 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-500/60 hover:shadow-md cursor-pointer"
>
{/* There are no brand logo assets in this project, so the image band
shows a representative product photo and falls back to a monogram
rather than a generic stock image that would misrepresent the brand. */}
{showImage ? (
<img
src={image_url}
alt={name}
loading="lazy"
onError={() => setImgError(true)}
className="h-28 w-full bg-ink-50 object-contain transition duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-28 w-full items-center justify-center bg-ink-900">
<span className="font-display text-3xl font-bold tracking-wide text-amber-500">
{initials}
</span>
</div>
)}
<div className="flex flex-1 flex-col gap-1.5 p-3">
<p className="truncate font-display text-[15px] font-semibold text-ink-950">
{name}
</p>
<div className="flex items-center gap-2.5 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Package className="h-3 w-3 shrink-0" />
{product_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<Tag className="h-3 w-3 shrink-0" />
{category_count}
</span>
</div>
{Array.isArray(categories) && categories.length > 0 && (
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{categories.slice(0, 3).map((c) => (
<span
key={c}
className="truncate rounded-full bg-ink-900/5 px-2 py-0.5 text-[10px] text-slate-600"
>
{String(c)}
</span>
))}
</div>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,42 @@
import { Boxes } from 'lucide-react';
import { BrandCard } from './BrandCard';
import { Spinner, EmptyState } from './Atoms';
export function BrandGrid({ brands, loading, error, emptyTitle, emptySubtitle, onSelect }) {
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner label="Fetching brands…" />
</div>
);
}
if (error) {
return (
<EmptyState
icon={Boxes}
title="Couldn't load brands"
subtitle={error}
/>
);
}
if (!brands || brands.length === 0) {
return (
<EmptyState
icon={Boxes}
title={emptyTitle || 'No brands yet'}
subtitle={emptySubtitle || 'Add a product from the User page, or ingest a brand from the Admin panel.'}
/>
);
}
// Denser than the product grid - a brand tile carries far less content.
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((b) => (
<BrandCard key={b.slug || b.name} brand={b} onClick={() => onSelect?.(b)} />
))}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { Sidebar } from '../components/Sidebar';
import { TopBar } from '../components/TopBar';
import { ProductGrid } from '../components/ProductGrid';
import { BrandGrid } from '../components/BrandGrid';
import { ProductModal } from '../components/ProductModal';
import { ChatPanel } from '../components/ChatPanel';
import { api } from '../api/client';
@@ -12,6 +13,14 @@ export function HomePage() {
const [brands, setBrands] = useState([]);
const [brandsLoading, setBrandsLoading] = useState(true);
// `selectedBrand === null` already means "All brands - show every product",
// so it cannot also mean "show the brand cards". This is the explicit
// landing-vs-listing switch.
const [view, setView] = useState('brands'); // 'brands' | 'products'
const [brandCards, setBrandCards] = useState([]);
const [brandCardsLoading, setBrandCardsLoading] = useState(true);
const [brandCardsError, setBrandCardsError] = useState(null);
const [categories, setCategories] = useState([]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
@@ -37,14 +46,27 @@ export function HomePage() {
.finally(() => setBrandsLoading(false));
}, []);
const loadBrandCards = useCallback(({ refresh = false } = {}) => {
setBrandCardsLoading(true);
setBrandCardsError(null);
api.getBrandCards({ refresh })
.then((res) => setBrandCards(res.brands || []))
.catch((err) => {
setBrandCards([]);
setBrandCardsError(err.message);
})
.finally(() => setBrandCardsLoading(false));
}, []);
// Initial load: health + brands, then poll health every 30s
useEffect(() => {
loadBrands();
loadBrandCards();
const checkHealth = () => api.getHealth().then(setHealth).catch(() => setHealth(null));
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => clearInterval(interval);
}, [loadBrands]);
}, [loadBrands, loadBrandCards]);
// Load categories + default browse listing whenever the brand changes
useEffect(() => {
@@ -87,13 +109,16 @@ export function HomePage() {
});
}, [selectedBrand, selectedCategory]);
// Re-browse whenever brand/category changes (only relevant once a search hasn't overridden it)
// Re-browse whenever brand/category changes (only relevant once a search
// hasn't overridden it). Gating on `view` also stops the landing page firing
// an unbounded /api/products request before the user has asked for one.
useEffect(() => {
if (mode === 'search' && !query) browse();
if (mode === 'search' && !query && view === 'products') browse();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBrand, selectedCategory]);
}, [selectedBrand, selectedCategory, view]);
function runSearch() {
setView('products');
if (!query.trim()) {
browse();
return;
@@ -129,9 +154,9 @@ export function HomePage() {
categoriesLoading={categoriesLoading}
selectedBrand={selectedBrand}
selectedCategory={selectedCategory}
onSelectBrand={(b) => { setSelectedBrand(b); setQuery(''); }}
onSelectBrand={(b) => { setSelectedBrand(b); setQuery(''); setView('products'); }}
onSelectCategory={setSelectedCategory}
onRefreshBrands={loadBrands}
onRefreshBrands={() => { loadBrands(); loadBrandCards({ refresh: true }); }}
/>
<div className="flex flex-1 flex-col overflow-hidden">
@@ -147,36 +172,70 @@ export function HomePage() {
setSelectedCategory(null);
setQuery('');
setMode('search');
setView('brands');
}}
/>
<main className="flex-1 overflow-y-auto">
{mode === 'search' ? (
<div className="p-6">
<div className="mb-4 flex items-center justify-between">
<p className="font-display text-sm font-semibold text-ink-900">
{hasSearched
? `Results for "${query}"`
: selectedBrand
? `Browsing ${selectedBrand}${selectedCategory ? ` · ${selectedCategory}` : ''}`
: 'Browsing All Brands'}
</p>
{products.length > 0 && <p className="text-xs text-slate-500">{products.length} shown</p>}
</div>
{view === 'brands' ? (
<>
<div className="mb-4 flex items-center justify-between">
<p className="font-display text-sm font-semibold text-ink-900">Browse Brands</p>
{brandCards.length > 0 && (
<p className="text-xs text-slate-500">
{brandCards.length} brands ·{' '}
{brandCards.reduce((n, b) => n + (b.product_count || 0), 0).toLocaleString()} products
</p>
)}
</div>
<ProductGrid
products={products}
loading={productsLoading}
error={productsError}
showSimilarity={hasSearched}
emptyTitle={brands.length === 0 ? 'No catalog data yet' : 'No products found'}
emptySubtitle={
brands.length === 0
? "Run `python scripts/seed_sample_data.py` in the backend to load sample brands, or use the Admin panel to ingest a real one."
: 'Try a different brand, category, or search term.'
}
onSelect={setSelectedProduct}
/>
<BrandGrid
brands={brandCards}
loading={brandCardsLoading}
error={brandCardsError}
emptyTitle="No catalog data yet"
emptySubtitle="Run `python scripts/seed_sample_data.py` in the backend to load sample brands, or use the Admin panel to ingest a real one."
onSelect={(b) => { setSelectedBrand(b.name); setQuery(''); setView('products'); }}
/>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => setView('brands')}
className="text-xs text-slate-500 transition hover:text-amber-600"
>
← All brands
</button>
<p className="font-display text-sm font-semibold text-ink-900">
{hasSearched
? `Results for "${query}"`
: selectedBrand
? `Browsing ${selectedBrand}${selectedCategory ? ` · ${selectedCategory}` : ''}`
: 'Browsing All Brands'}
</p>
</div>
{products.length > 0 && <p className="text-xs text-slate-500">{products.length} shown</p>}
</div>
<ProductGrid
products={products}
loading={productsLoading}
error={productsError}
showSimilarity={hasSearched}
emptyTitle={brands.length === 0 ? 'No catalog data yet' : 'No products found'}
emptySubtitle={
brands.length === 0
? "Run `python scripts/seed_sample_data.py` in the backend to load sample brands, or use the Admin panel to ingest a real one."
: 'Try a different brand, category, or search term.'
}
onSelect={setSelectedProduct}
/>
</>
)}
</div>
) : (
<ChatPanel brand={selectedBrand} onSelectSource={setSelectedProduct} />