Merge staging: filter sheet drawers
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { ChevronDown, SlidersHorizontal, X } from 'lucide-react';
|
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||||
import type { ShopifyCollection } from '@reacteditor/field-shopify';
|
import type { ShopifyCollection } from '@reacteditor/field-shopify';
|
||||||
import {
|
import {
|
||||||
useCollectionProducts,
|
useCollectionProducts,
|
||||||
@@ -11,6 +11,8 @@ import { ProductCard } from './product-card';
|
|||||||
import { Typography } from '@/components/Typography';
|
import { Typography } from '@/components/Typography';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type FilterOption = { label: string };
|
type FilterOption = { label: string };
|
||||||
@@ -151,48 +153,8 @@ function Sidebar({
|
|||||||
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters =
|
|
||||||
active.availability ||
|
|
||||||
active.productTypes.length > 0 ||
|
|
||||||
active.vendors.length > 0 ||
|
|
||||||
active.tags.length > 0 ||
|
|
||||||
active.colors.length > 0 ||
|
|
||||||
active.styles.length > 0 ||
|
|
||||||
active.sizes.length > 0 ||
|
|
||||||
active.materials.length > 0 ||
|
|
||||||
active.minPrice !== '' ||
|
|
||||||
active.maxPrice !== '' ||
|
|
||||||
Object.values(active.metafieldValues).some((arr) => arr.length > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-full shrink-0 md:w-52 lg:w-56">
|
<div className="w-full">
|
||||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.15em]">Filters</span>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
onChange({
|
|
||||||
availability: false,
|
|
||||||
productTypes: [],
|
|
||||||
vendors: [],
|
|
||||||
tags: [],
|
|
||||||
colors: [],
|
|
||||||
styles: [],
|
|
||||||
sizes: [],
|
|
||||||
materials: [],
|
|
||||||
minPrice: '',
|
|
||||||
maxPrice: '',
|
|
||||||
metafieldValues: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X size={12} /> Clear all
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.showAvailability === 'yes' && (
|
{props.showAvailability === 'yes' && (
|
||||||
<FilterGroup label="Availability">
|
<FilterGroup label="Availability">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -352,7 +314,7 @@ function Sidebar({
|
|||||||
</FilterGroup>
|
</FilterGroup>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</aside>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +362,7 @@ export function CollectionView(props: CollectionProps) {
|
|||||||
|
|
||||||
const [sort, setSort] = useState<CollectionSortKey>(defaultSort);
|
const [sort, setSort] = useState<CollectionSortKey>(defaultSort);
|
||||||
const [reverse, setReverse] = useState(false);
|
const [reverse, setReverse] = useState(false);
|
||||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [active, setActive] = useState<ActiveFilters>({
|
const [active, setActive] = useState<ActiveFilters>({
|
||||||
availability: false,
|
availability: false,
|
||||||
productTypes: [],
|
productTypes: [],
|
||||||
@@ -419,6 +381,22 @@ export function CollectionView(props: CollectionProps) {
|
|||||||
setActive((prev) => ({ ...prev, ...patch }));
|
setActive((prev) => ({ ...prev, ...patch }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setActive({
|
||||||
|
availability: false,
|
||||||
|
productTypes: [],
|
||||||
|
vendors: [],
|
||||||
|
tags: [],
|
||||||
|
colors: [],
|
||||||
|
styles: [],
|
||||||
|
sizes: [],
|
||||||
|
materials: [],
|
||||||
|
minPrice: '',
|
||||||
|
maxPrice: '',
|
||||||
|
metafieldValues: {},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const productFilters = buildProductFilters(active);
|
const productFilters = buildProductFilters(active);
|
||||||
|
|
||||||
const handleSortChange = (value: string) => {
|
const handleSortChange = (value: string) => {
|
||||||
@@ -491,96 +469,82 @@ export function CollectionView(props: CollectionProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Mobile filter toggle */}
|
{/* Filter + sort bar */}
|
||||||
<div className="mb-4 flex items-center justify-between md:hidden">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<button
|
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||||
type="button"
|
<SheetTrigger asChild>
|
||||||
onClick={() => setMobileFiltersOpen((o) => !o)}
|
<button
|
||||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium"
|
type="button"
|
||||||
>
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||||
<SlidersHorizontal size={14} />
|
>
|
||||||
Filters
|
<SlidersHorizontal size={14} />
|
||||||
</button>
|
Filters
|
||||||
<Select value={sortValue} onValueChange={handleSortChange}>
|
</button>
|
||||||
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
</SheetTrigger>
|
||||||
<SelectValue>
|
<SheetContent side="left" className="w-full sm:max-w-md">
|
||||||
{[...SORT_OPTIONS, { label: 'Price: High to Low', value: 'PRICE_DESC' }].find((o) => o.value === sortValue)?.label}
|
<SheetHeader>
|
||||||
</SelectValue>
|
<SheetTitle>Filters</SheetTitle>
|
||||||
</SelectTrigger>
|
</SheetHeader>
|
||||||
<SelectContent>
|
<div className="flex-1 overflow-y-auto px-4">
|
||||||
{SORT_OPTIONS.map((o) => (
|
<Sidebar props={props} active={active} onChange={patchActive} />
|
||||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
</div>
|
||||||
))}
|
<SheetFooter className="flex-row gap-2 border-t border-border">
|
||||||
<SelectItem value="PRICE_DESC">Price: High to Low</SelectItem>
|
<Button variant="outline" className="flex-1" onClick={clearAll}>
|
||||||
</SelectContent>
|
Clear
|
||||||
</Select>
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<p className="hidden text-sm text-muted-foreground sm:block">
|
||||||
|
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
||||||
|
</p>
|
||||||
|
<Select value={sortValue} onValueChange={handleSortChange}>
|
||||||
|
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
||||||
|
<SelectValue>
|
||||||
|
{[...SORT_OPTIONS, { label: 'Price: High to Low', value: 'PRICE_DESC' }].find((o) => o.value === sortValue)?.label}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SORT_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="PRICE_DESC">Price: High to Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile filter panel */}
|
{/* Grid */}
|
||||||
{mobileFiltersOpen && (
|
<div className={cn('grid gap-x-6 gap-y-10', colClass[columns])}>
|
||||||
<div className="mb-6 rounded-lg border border-border p-4 md:hidden">
|
{loading
|
||||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
? Array.from({ length: limit }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||||
|
))
|
||||||
|
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && products.length === 0 && (
|
||||||
|
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||||
|
No products found in this collection.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-10">
|
{hasNextPage && !loading && (
|
||||||
{/* Desktop sidebar */}
|
<div className="mt-12 flex justify-center">
|
||||||
<div className="hidden md:block">
|
<button
|
||||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
type="button"
|
||||||
|
onClick={fetchMore}
|
||||||
|
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Product area */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{/* Sort + count bar */}
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
|
||||||
</p>
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<Select value={sortValue} onValueChange={handleSortChange}>
|
|
||||||
<SelectTrigger className="h-auto px-3 py-1.5 text-sm">
|
|
||||||
<SelectValue>
|
|
||||||
{[...SORT_OPTIONS, { label: 'Price: High to Low', value: 'PRICE_DESC' }].find((o) => o.value === sortValue)?.label}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SORT_OPTIONS.map((o) => (
|
|
||||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="PRICE_DESC">Price: High to Low</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div className={cn('grid gap-x-6 gap-y-10', colClass[columns])}>
|
|
||||||
{loading
|
|
||||||
? Array.from({ length: limit }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
|
||||||
))
|
|
||||||
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!loading && products.length === 0 && (
|
|
||||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
|
||||||
No products found in this collection.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasNextPage && !loading && (
|
|
||||||
<div className="mt-12 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={fetchMore}
|
|
||||||
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { ChevronDown, SlidersHorizontal, X } from 'lucide-react';
|
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||||
import { useShopifySearch, type SearchFilters, type SortOption } from '@/hooks/use-shopify-search';
|
import { useShopifySearch, type SearchFilters, type SortOption } from '@/hooks/use-shopify-search';
|
||||||
|
|
||||||
import { ProductCard } from './product-card';
|
import { ProductCard } from './product-card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type FilterOption = { label: string };
|
type FilterOption = { label: string };
|
||||||
@@ -146,48 +148,8 @@ function Sidebar({
|
|||||||
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters =
|
|
||||||
active.availability ||
|
|
||||||
active.productTypes.length > 0 ||
|
|
||||||
active.vendors.length > 0 ||
|
|
||||||
active.tags.length > 0 ||
|
|
||||||
active.colors.length > 0 ||
|
|
||||||
active.styles.length > 0 ||
|
|
||||||
active.sizes.length > 0 ||
|
|
||||||
active.materials.length > 0 ||
|
|
||||||
active.minPrice !== '' ||
|
|
||||||
active.maxPrice !== '' ||
|
|
||||||
Object.values(active.metafieldValues).some((arr) => arr.length > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-full shrink-0 md:w-52 lg:w-56">
|
<div className="w-full">
|
||||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.15em]">Filters</span>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
onChange({
|
|
||||||
availability: false,
|
|
||||||
productTypes: [],
|
|
||||||
vendors: [],
|
|
||||||
tags: [],
|
|
||||||
colors: [],
|
|
||||||
styles: [],
|
|
||||||
sizes: [],
|
|
||||||
materials: [],
|
|
||||||
minPrice: '',
|
|
||||||
maxPrice: '',
|
|
||||||
metafieldValues: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X size={12} /> Clear all
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.showAvailability === 'yes' && (
|
{props.showAvailability === 'yes' && (
|
||||||
<FilterGroup label="Availability">
|
<FilterGroup label="Availability">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -347,7 +309,7 @@ function Sidebar({
|
|||||||
</FilterGroup>
|
</FilterGroup>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</aside>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +322,7 @@ export function SearchProductsView(props: SearchProductsProps) {
|
|||||||
const [query, setQuery] = useState(initialQ);
|
const [query, setQuery] = useState(initialQ);
|
||||||
const [inputValue, setInputValue] = useState(initialQ);
|
const [inputValue, setInputValue] = useState(initialQ);
|
||||||
const [sort, setSort] = useState<SortOption>(props.defaultSort);
|
const [sort, setSort] = useState<SortOption>(props.defaultSort);
|
||||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [active, setActive] = useState<ActiveFilters>({
|
const [active, setActive] = useState<ActiveFilters>({
|
||||||
availability: false,
|
availability: false,
|
||||||
productTypes: [],
|
productTypes: [],
|
||||||
@@ -379,6 +341,22 @@ export function SearchProductsView(props: SearchProductsProps) {
|
|||||||
setActive((prev) => ({ ...prev, ...patch }));
|
setActive((prev) => ({ ...prev, ...patch }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setActive({
|
||||||
|
availability: false,
|
||||||
|
productTypes: [],
|
||||||
|
vendors: [],
|
||||||
|
tags: [],
|
||||||
|
colors: [],
|
||||||
|
styles: [],
|
||||||
|
sizes: [],
|
||||||
|
materials: [],
|
||||||
|
minPrice: '',
|
||||||
|
maxPrice: '',
|
||||||
|
metafieldValues: {},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filters: SearchFilters = {
|
const filters: SearchFilters = {
|
||||||
q: query,
|
q: query,
|
||||||
sort,
|
sort,
|
||||||
@@ -449,113 +427,102 @@ export function SearchProductsView(props: SearchProductsProps) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile filter toggle */}
|
{/* Search bar (desktop only — mobile lives in header above) */}
|
||||||
<div className="mb-4 flex items-center justify-between md:hidden">
|
<form onSubmit={handleSearch} className="mb-4 hidden gap-2 md:flex">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
placeholder="Search products…"
|
||||||
|
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={() => setMobileFiltersOpen((o) => !o)}
|
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
|
||||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium"
|
|
||||||
>
|
>
|
||||||
<SlidersHorizontal size={14} />
|
Search
|
||||||
Filters
|
|
||||||
</button>
|
</button>
|
||||||
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
|
</form>
|
||||||
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
|
||||||
<SelectValue>{SORT_OPTIONS.find((o) => o.value === sort)?.label}</SelectValue>
|
{/* Filter + sort bar */}
|
||||||
</SelectTrigger>
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<SelectContent>
|
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||||
{SORT_OPTIONS.map((o) => (
|
<SheetTrigger asChild>
|
||||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</SelectContent>
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||||
</Select>
|
>
|
||||||
|
<SlidersHorizontal size={14} />
|
||||||
|
Filters
|
||||||
|
</button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-full sm:max-w-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Filters</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto px-4">
|
||||||
|
<Sidebar props={props} active={active} onChange={patchActive} />
|
||||||
|
</div>
|
||||||
|
<SheetFooter className="flex-row gap-2 border-t border-border">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={clearAll}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<p className="hidden text-sm text-muted-foreground sm:block">
|
||||||
|
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
||||||
|
</p>
|
||||||
|
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
|
||||||
|
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
||||||
|
<SelectValue>{SORT_OPTIONS.find((o) => o.value === sort)?.label}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SORT_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile filter panel */}
|
{error && (
|
||||||
{mobileFiltersOpen && (
|
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||||
<div className="mb-6 rounded-lg border border-border p-4 md:hidden">
|
{error}
|
||||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className={cn('grid gap-x-6 gap-y-10', colClass[props.columns])}>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: props.limit }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||||
|
))
|
||||||
|
: products.map((p) => <ProductCard key={p.id} product={p} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && products.length === 0 && !error && (
|
||||||
|
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||||
|
No products found.{query ? ` Try a different search term.` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-10">
|
{hasNextPage && !loading && (
|
||||||
{/* Desktop sidebar */}
|
<div className="mt-12 flex justify-center">
|
||||||
<div className="hidden md:block">
|
<button
|
||||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
type="button"
|
||||||
|
onClick={fetchMore}
|
||||||
|
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Product area */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{/* Search bar (desktop only) */}
|
|
||||||
<form onSubmit={handleSearch} className="mb-4 hidden gap-2 md:flex">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
placeholder="Search products…"
|
|
||||||
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Sort + count bar */}
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
|
||||||
</p>
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
|
|
||||||
<SelectTrigger className="h-auto px-3 py-1.5 text-sm">
|
|
||||||
<SelectValue>{SORT_OPTIONS.find((o) => o.value === sort)?.label}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SORT_OPTIONS.map((o) => (
|
|
||||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div className={cn('grid gap-x-6 gap-y-10', colClass[props.columns])}>
|
|
||||||
{loading
|
|
||||||
? Array.from({ length: props.limit }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
|
||||||
))
|
|
||||||
: products.map((p) => <ProductCard key={p.id} product={p} />)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!loading && products.length === 0 && !error && (
|
|
||||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
|
||||||
No products found.{query ? ` Try a different search term.` : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasNextPage && !loading && (
|
|
||||||
<div className="mt-12 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={fetchMore}
|
|
||||||
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user