Files
shopify-template/components/shopify/search-dialog.tsx
T
Rami BitarandClaude Opus 5 2ef5639a2e Add collection filters and sort, out-of-stock options, gallery drag
- 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
2026-08-01 12:27:05 -04:00

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;