update backend apis
This commit is contained in:
@@ -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 } = {}) =>
|
||||
|
||||
79
src/components/BrandCard.jsx
Normal file
79
src/components/BrandCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/BrandGrid.jsx
Normal file
42
src/components/BrandGrid.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user