'use client'; import { useEffect, useState, useRef } from 'react'; import { ChevronDown } from 'lucide-react'; import { useProductListing, sortOptions, isActiveInTree, type CategoryNode, } from '@/core/hooks/use-product-listing'; import { ProductGrid } from '@/ui/product/product-grid'; import { LoadingSpinner } from '@/ui/shared/loading-spinner'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; /** Radix Select items can't carry an empty-string value, so "no filter" * round-trips through this sentinel and is written back as ''. */ const ALL = '__all__'; /** Recursive dropdown items for nested categories */ function CategoryDropdownItems({ items, depth, selectedId, onSelect, }: { items: CategoryNode[]; depth: number; selectedId: string; onSelect: (id: string) => void; }) { return ( <> {items.map((child) => (
{child.children.length > 0 && ( )}
))} ); } /** Category chip with dropdown for subcategories */ function CategoryChip({ category, selectedId, onSelect, tc, }: { category: CategoryNode; selectedId: string; onSelect: (id: string) => void; tc: (key: string) => string; }) { const [open, setOpen] = useState(false); const ref = useRef(null); const hasChildren = category.children.length > 0; const isActive = isActiveInTree(category, selectedId); // Find the display name for the selected subcategory function findName(nodes: CategoryNode[], id: string): string | null { for (const n of nodes) { if (n.id === id) return n.name; const found = findName(n.children, id); if (found) return found; } return null; } const selectedChildName = isActive && selectedId !== category.id ? findName(category.children, selectedId) : null; // Close dropdown on outside click useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [open]); if (!hasChildren) { return ( ); } return (
{open && (
{/* "All in [category]" option */}
{/* Recursive children */}
setOpen(false)}>
)}
); } /** * Append a product count to a facet value label when one is known. * * `metafieldCounts` comes from `getMetafieldFilters()`, which not every mode * or store can answer — a missing entry means "no count available", NOT zero, * so the bare label is returned unchanged rather than showing "(0)" on a facet * that may well have products behind it. */ function withCount(label: string, count: number | undefined): string { return typeof count === 'number' ? `${label} (${count})` : label; } /** * Product listing page (filters + sort + grid + load-more). All data and * URL-state behavior comes from `useProductListing`; this component is * presentation only. Must be rendered inside `` (URL params). */ export function ProductListing() { const t = useTranslations('products'); const tc = useTranslations('common'); const { products, loading, loadingMore, page, totalPages, total, categories, brands, tags, metafieldDefs, metafieldCounts, searchQuery, categoryId, brandId, tagId, sortIndex, handleLoadMore, updateParam, handleCategorySelect, getSelectedMetafieldValues, setMetafieldValue, } = useProductListing(); return (
{/* Page Header */}

{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}

{!loading && (

{total} {total === 1 ? tc('product') : tc('products')} {tc('found')}

)}
{/* Filters and Sort */}
{/* Category Filter */} {categories.length > 0 && (
{categories.map((cat) => ( string} /> ))}
)} {/* Brand, Tag & Sort row */}
{/* Brand Filter */} {brands.length > 0 && ( )} {/* Tag Filter */} {tags.length > 0 && ( )} {/* Custom-field (metafield) facets — rendered per definition type. Only definitions flagged `filterable: true` and published to this sales channel reach this list (see `metafieldDefs` filter in the hook). The merchant's display name (`def.name`) is shown as-is — `enumValues` come from the merchant's dashboard config. */} {metafieldDefs.map((def) => { const selected = getSelectedMetafieldValues(def.key); const counts = metafieldCounts[def.key]; if (def.type === 'BOOLEAN') { const isOn = selected.includes('true'); return ( ); } if (def.type === 'SELECT') { return ( ); } // MULTI_SELECT — chip toggles. Each click adds/removes one value. return (
{def.name}: {(def.enumValues || []).map((v) => { const active = selected.includes(v.value); return ( ); })}
); })} {/* Sort */}
{/* Products Grid */} {loading ? (
) : ( <> {/* Load More */} {page < totalPages && (
)} )}
); }