diff --git a/src/api/client.js b/src/api/client.js
index 3249230..89d6a65 100644
--- a/src/api/client.js
+++ b/src/api/client.js
@@ -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 } = {}) =>
diff --git a/src/components/BrandCard.jsx b/src/components/BrandCard.jsx
new file mode 100644
index 0000000..9579216
--- /dev/null
+++ b/src/components/BrandCard.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/BrandGrid.jsx b/src/components/BrandGrid.jsx
new file mode 100644
index 0000000..224e63e
--- /dev/null
+++ b/src/components/BrandGrid.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!brands || brands.length === 0) {
+ return (
+
+ );
+ }
+
+ // Denser than the product grid - a brand tile carries far less content.
+ return (
+
+ {brands.map((b) => (
+ onSelect?.(b)} />
+ ))}
+
+ );
+}
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index e860cf9..391591e 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -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 }); }}
/>
@@ -147,36 +172,70 @@ export function HomePage() {
setSelectedCategory(null);
setQuery('');
setMode('search');
+ setView('brands');
}}
/>
{mode === 'search' ? (
-
-
- {hasSearched
- ? `Results for "${query}"`
- : selectedBrand
- ? `Browsing ${selectedBrand}${selectedCategory ? ` · ${selectedCategory}` : ''}`
- : 'Browsing All Brands'}
-
- {products.length > 0 &&
{products.length} shown
}
-
+ {view === 'brands' ? (
+ <>
+
+
Browse Brands
+ {brandCards.length > 0 && (
+
+ {brandCards.length} brands ·{' '}
+ {brandCards.reduce((n, b) => n + (b.product_count || 0), 0).toLocaleString()} products
+
+ )}
+
-
+
{ setSelectedBrand(b.name); setQuery(''); setView('products'); }}
+ />
+ >
+ ) : (
+ <>
+
+
+
+
+ {hasSearched
+ ? `Results for "${query}"`
+ : selectedBrand
+ ? `Browsing ${selectedBrand}${selectedCategory ? ` · ${selectedCategory}` : ''}`
+ : 'Browsing All Brands'}
+
+
+ {products.length > 0 &&
{products.length} shown
}
+
+
+
+ >
+ )}
) : (