Move collection and search filters into a Sheet drawer

Replace the always-visible sidebar and inline mobile filter panel with a
single Filters button that opens a left-side Sheet, with Clear (outline)
and Search (default) actions in the footer for consistency across pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Rami Bitar
2026-05-09 15:20:50 -04:00
parent 4f95e56513
commit 906f2934fb
2 changed files with 201 additions and 270 deletions

View File

@@ -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>
); );

View File

@@ -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>
); );