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,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;
|
||||
Reference in New Issue
Block a user