Template
- Collections: filters and sort via the Storefront API — the products connection now takes `filters`/`after` and returns its facets, with cursor-based load more on the collection page - Extract ProductFilters (renamed from SearchFilters) and a shared ProductToolbar so search and collections use the same chrome - PDP: mark option values with no in-stock variant using a diagonal strike, computed against the other selected options - PDP: pointer drag-scrolling for the mobile image carousel, since a scroll container doesn't drag with a mouse - Header/cart: circular icon buttons, tighter spacing, and icon sizes set by class (Button's base CSS clamps un-classed svgs to size-4) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
177 lines
5.3 KiB
TypeScript
177 lines
5.3 KiB
TypeScript
'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"
|
|
className="rounded-full"
|
|
>
|
|
<RiSearchLine className="size-5" />
|
|
</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;
|