Template
Add product search with autocomplete, filters, and sort
- Header: search button opening a command-palette dialog with debounced product suggestions, a term chip, and View All - Command: shadcn-shaped primitives built on the project's own Dialog so no cmdk/radix dependency is introduced - /search: results grid with item count, sort (relevance, price asc/desc), and cursor-based load more - Filters: driven by the API's productFilters facets — colour swatches, labelled lists with counts, and a price range — round-tripped through each facet's raw input string so no filter shape is hardcoded Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
co-authored by
Claude Opus 5
parent
e04f1e0405
commit
cc901b9ce8
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useCartStore } from '@/hooks/use-shopify-cart';
|
||||
import CartDrawer from '@/components/shopify/cart-drawer';
|
||||
import SearchDialog from '@/components/shopify/search-dialog';
|
||||
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -86,6 +87,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-x-1">
|
||||
<SearchDialog />
|
||||
<CartIcon />
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from '@/components/ui/command';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/app/components/ui/loader';
|
||||
import { RiSearchLine, RiImageLine } from '@remixicon/react';
|
||||
import {
|
||||
searchSuggestions,
|
||||
type SearchSuggestion,
|
||||
} from '@/hooks/use-shopify-search';
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
const SUGGESTION_COUNT = 3;
|
||||
|
||||
const formatPrice = (amount: string) => `$${parseFloat(amount).toFixed(2)}`;
|
||||
|
||||
const SearchDialog: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState('');
|
||||
const [results, setResults] = useState<SearchSuggestion[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
// Guards against a slow early request overwriting a newer one's results.
|
||||
const requestId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const query = term.trim();
|
||||
|
||||
if (!query) {
|
||||
setResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
const id = ++requestId.current;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const { products } = await searchSuggestions(query, SUGGESTION_COUNT);
|
||||
if (id !== requestId.current) return;
|
||||
setResults(products);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
if (id === requestId.current) setResults([]);
|
||||
} finally {
|
||||
if (id === requestId.current) setSearching(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [term]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTerm('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
const goToSearchPage = () => {
|
||||
const query = term.trim();
|
||||
close();
|
||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search');
|
||||
};
|
||||
|
||||
const goToProduct = (handle: string) => {
|
||||
close();
|
||||
router.push(`/products/${handle}`);
|
||||
};
|
||||
|
||||
const hasQuery = Boolean(term.trim());
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Search"
|
||||
>
|
||||
<RiSearchLine size={20} />
|
||||
</Button>
|
||||
|
||||
<CommandDialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
value={term}
|
||||
onValueChange={setTerm}
|
||||
onClear={() => setTerm('')}
|
||||
onClose={close}
|
||||
placeholder="Search products"
|
||||
aria-label="Search products"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') goToSearchPage();
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasQuery && (
|
||||
<>
|
||||
<div className="px-4 pb-2">
|
||||
<button
|
||||
onClick={goToSearchPage}
|
||||
className="rounded-full border border-border px-3 py-1 text-sm text-foreground transition-colors hover:border-foreground"
|
||||
>
|
||||
{term.trim()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CommandList>
|
||||
{searching && results.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader size={20} />
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<CommandEmpty>No products found.</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup heading="Products">
|
||||
{results.map((product) => (
|
||||
<CommandItem
|
||||
key={product.id}
|
||||
onClick={() => goToProduct(product.handle)}
|
||||
>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
|
||||
{product.featuredImage ? (
|
||||
<img
|
||||
src={product.featuredImage.url}
|
||||
alt={product.featuredImage.altText || product.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-400">
|
||||
<RiImageLine size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums tracking-tight text-foreground">
|
||||
{formatPrice(
|
||||
product.priceRange.minVariantPrice.amount
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="flex justify-center px-4 pb-5 pt-2">
|
||||
<Button onClick={goToSearchPage} className="px-8">
|
||||
View All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDialog;
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetBody,
|
||||
AnimatePresence,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
|
||||
import { swatchColorForName } from '@/config/swatches';
|
||||
import type { SearchFilter, SearchFilterValue } from '@/hooks/use-shopify-search';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filters: SearchFilter[];
|
||||
activeFilters: string[];
|
||||
onActiveFiltersChange: (filters: string[]) => void;
|
||||
}
|
||||
|
||||
// Colour facets render as swatches; everything else as a labelled list.
|
||||
const isColorFilter = (filter: SearchFilter) =>
|
||||
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
|
||||
|
||||
const SearchFilters: React.FC<SearchFiltersProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
activeFilters,
|
||||
onActiveFiltersChange,
|
||||
}) => {
|
||||
const [priceMin, setPriceMin] = useState('');
|
||||
const [priceMax, setPriceMax] = useState('');
|
||||
|
||||
const listFilters = filters.filter((filter) => filter.type === 'LIST');
|
||||
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
|
||||
|
||||
const activeSet = new Set(activeFilters);
|
||||
// Price is rebuilt from the inputs rather than toggled, so track it apart.
|
||||
const activePriceInput = activeFilters.find((input) =>
|
||||
input.includes('"price"')
|
||||
);
|
||||
|
||||
const toggleValue = (value: SearchFilterValue) => {
|
||||
onActiveFiltersChange(
|
||||
activeSet.has(value.input)
|
||||
? activeFilters.filter((input) => input !== value.input)
|
||||
: [...activeFilters, value.input]
|
||||
);
|
||||
};
|
||||
|
||||
const applyPrice = () => {
|
||||
const min = parseFloat(priceMin);
|
||||
const max = parseFloat(priceMax);
|
||||
const withoutPrice = activeFilters.filter(
|
||||
(input) => !input.includes('"price"')
|
||||
);
|
||||
|
||||
if (Number.isNaN(min) && Number.isNaN(max)) {
|
||||
onActiveFiltersChange(withoutPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
const price: { min?: number; max?: number } = {};
|
||||
if (!Number.isNaN(min)) price.min = min;
|
||||
if (!Number.isNaN(max)) price.max = max;
|
||||
|
||||
onActiveFiltersChange([...withoutPrice, JSON.stringify({ price })]);
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setPriceMin('');
|
||||
setPriceMax('');
|
||||
onActiveFiltersChange([]);
|
||||
};
|
||||
|
||||
// Labels for the chips at the top of the panel.
|
||||
const activeChips = filters
|
||||
.flatMap((filter) => filter.values)
|
||||
.filter((value) => activeSet.has(value.input));
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange} side="left">
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<SheetContent className="w-full max-w-sm" showCloseButton={false}>
|
||||
<SheetHeader className="min-h-0 border-b-0 px-5 py-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<SheetTitle className="text-lg font-medium">Filters</SheetTitle>
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close filters"
|
||||
>
|
||||
<RiCloseLine size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody className="px-5">
|
||||
{/* Active selections */}
|
||||
{(activeChips.length > 0 || activePriceInput) && (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||
{activeChips.map((value) => (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => toggleValue(value)}
|
||||
className="flex items-center gap-x-1 rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
|
||||
>
|
||||
{value.label}
|
||||
<RiCloseLine size={12} />
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
onClick={clearAll}
|
||||
variant="link"
|
||||
className="h-auto px-0 text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No filters available for these results.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{priceFilter && (
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-3 text-base text-foreground">Price</h3>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={priceMin}
|
||||
onChange={(event) => setPriceMin(event.target.value)}
|
||||
placeholder="$ From"
|
||||
aria-label="Minimum price"
|
||||
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={priceMax}
|
||||
onChange={(event) => setPriceMax(event.target.value)}
|
||||
placeholder="$ To"
|
||||
aria-label="Maximum price"
|
||||
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
onClick={applyPrice}
|
||||
size="icon"
|
||||
aria-label="Apply price range"
|
||||
className="shrink-0"
|
||||
>
|
||||
<RiCheckLine size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Facets */}
|
||||
{listFilters.map((filter) => (
|
||||
<div key={filter.id} className="mb-8">
|
||||
<h3 className="mb-3 text-base text-foreground">
|
||||
{filter.label}
|
||||
</h3>
|
||||
|
||||
{isColorFilter(filter) ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filter.values.map((value) => {
|
||||
const isActive = activeSet.has(value.input);
|
||||
const color = swatchColorForName(value.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => toggleValue(value)}
|
||||
title={`${value.label} (${value.count})`}
|
||||
aria-label={value.label}
|
||||
aria-pressed={isActive}
|
||||
className={`h-8 w-8 rounded-full transition-shadow ${
|
||||
isActive
|
||||
? 'ring-2 ring-foreground ring-offset-2'
|
||||
: 'ring-1 ring-border hover:ring-foreground/40'
|
||||
}`}
|
||||
style={{ backgroundColor: color ?? undefined }}
|
||||
>
|
||||
{!color && (
|
||||
<span className="text-[10px]">
|
||||
{value.label.at(0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filter.values.map((value) => {
|
||||
const isActive = activeSet.has(value.input);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => toggleValue(value)}
|
||||
aria-pressed={isActive}
|
||||
className="flex items-center justify-between py-1.5 text-left text-sm text-foreground hover:text-muted-foreground"
|
||||
>
|
||||
<span>
|
||||
{value.label} ({value.count})
|
||||
</span>
|
||||
{isActive && <RiCheckLine size={16} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFilters;
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ProductCard from './product-card';
|
||||
import SearchFilters from './search-filters';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/app/components/ui/loader';
|
||||
import { RiEqualizerLine, RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
|
||||
import {
|
||||
searchProducts,
|
||||
type SearchFilter,
|
||||
type SearchSortKey,
|
||||
} from '@/hooks/use-shopify-search';
|
||||
import type { Product } from '@/hooks/use-shopify-products';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
interface SortOption {
|
||||
label: string;
|
||||
sortKey: SearchSortKey;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ label: 'Best Matches', sortKey: 'RELEVANCE', reverse: false },
|
||||
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
|
||||
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
|
||||
];
|
||||
|
||||
const SearchResults: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get('q') ?? '';
|
||||
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [filters, setFilters] = useState<SearchFilter[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
|
||||
const [sortIndex, setSortIndex] = useState(0);
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
|
||||
const sort = SORT_OPTIONS[sortIndex];
|
||||
// Serialised so the effect re-runs when the selection changes, not the array.
|
||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await searchProducts({
|
||||
// An empty term still returns the catalogue, which is what an
|
||||
// unqualified /search visit should show.
|
||||
query,
|
||||
first: PAGE_SIZE,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setProducts(result.products);
|
||||
setTotalCount(result.totalCount);
|
||||
setCursor(result.endCursor);
|
||||
setHasNextPage(result.hasNextPage);
|
||||
// Facet counts change with the result set, but keep the panel stable
|
||||
// while filters are applied so options don't vanish mid-selection.
|
||||
if (activeFilters.length === 0) setFilters(result.filters);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('Search failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Search failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, sort.sortKey, sort.reverse, activeKey]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (loadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const result = await searchProducts({
|
||||
query,
|
||||
first: PAGE_SIZE,
|
||||
after: cursor,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
setProducts((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
return [...prev, ...result.products.filter((p) => !seen.has(p.id))];
|
||||
});
|
||||
setCursor(result.endCursor);
|
||||
setHasNextPage(result.hasNextPage);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more results:', err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-background py-10">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||
Search
|
||||
</h1>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
variant="ghost"
|
||||
className="gap-x-2 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
<RiEqualizerLine size={18} />
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
|
||||
</span>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => setSortOpen((prev) => !prev)}
|
||||
variant="ghost"
|
||||
aria-expanded={sortOpen}
|
||||
className="gap-x-1 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
Sort
|
||||
<RiArrowDownSLine size={16} />
|
||||
</Button>
|
||||
|
||||
{sortOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSortOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
|
||||
{SORT_OPTIONS.map((option, index) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
setSortIndex(index);
|
||||
setSortOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
{option.label}
|
||||
{index === sortIndex && <RiCheckLine size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mt-8">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="aspect-square bg-zinc-100"></div>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="h-4 w-4/5 bg-zinc-200"></div>
|
||||
<div className="h-4 w-1/4 bg-zinc-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No products matched{query ? ` “${query}”` : ' your filters'}.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="mt-16 flex justify-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{loadingMore && <Loader size={16} />}
|
||||
{loadingMore ? 'Loading' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchFilters
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
activeFilters={activeFilters}
|
||||
onActiveFiltersChange={setActiveFilters}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
Reference in New Issue
Block a user