Template
- 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
242 lines
8.0 KiB
TypeScript
242 lines
8.0 KiB
TypeScript
'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;
|