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