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:
Rami Bitar
2026-08-01 12:15:49 -04:00
co-authored by Claude Opus 5
parent e04f1e0405
commit cc901b9ce8
8 changed files with 1084 additions and 0 deletions
+2
View File
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
@@ -86,6 +87,7 @@ const Header: React.FC<HeaderProps> = ({
{/* Actions */}
<div className="flex items-center gap-x-1">
<SearchDialog />
<CartIcon />
{/* Mobile hamburger */}
+175
View File
@@ -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;
+238
View File
@@ -0,0 +1,238 @@
'use client';
import React, { useState } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
import { swatchColorForName } from '@/config/swatches';
import type { SearchFilter, SearchFilterValue } from '@/hooks/use-shopify-search';
interface SearchFiltersProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: SearchFilter[];
activeFilters: string[];
onActiveFiltersChange: (filters: string[]) => void;
}
// Colour facets render as swatches; everything else as a labelled list.
const isColorFilter = (filter: SearchFilter) =>
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
const SearchFilters: React.FC<SearchFiltersProps> = ({
open,
onOpenChange,
filters,
activeFilters,
onActiveFiltersChange,
}) => {
const [priceMin, setPriceMin] = useState('');
const [priceMax, setPriceMax] = useState('');
const listFilters = filters.filter((filter) => filter.type === 'LIST');
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
const activeSet = new Set(activeFilters);
// Price is rebuilt from the inputs rather than toggled, so track it apart.
const activePriceInput = activeFilters.find((input) =>
input.includes('"price"')
);
const toggleValue = (value: SearchFilterValue) => {
onActiveFiltersChange(
activeSet.has(value.input)
? activeFilters.filter((input) => input !== value.input)
: [...activeFilters, value.input]
);
};
const applyPrice = () => {
const min = parseFloat(priceMin);
const max = parseFloat(priceMax);
const withoutPrice = activeFilters.filter(
(input) => !input.includes('"price"')
);
if (Number.isNaN(min) && Number.isNaN(max)) {
onActiveFiltersChange(withoutPrice);
return;
}
const price: { min?: number; max?: number } = {};
if (!Number.isNaN(min)) price.min = min;
if (!Number.isNaN(max)) price.max = max;
onActiveFiltersChange([...withoutPrice, JSON.stringify({ price })]);
};
const clearAll = () => {
setPriceMin('');
setPriceMax('');
onActiveFiltersChange([]);
};
// Labels for the chips at the top of the panel.
const activeChips = filters
.flatMap((filter) => filter.values)
.filter((value) => activeSet.has(value.input));
return (
<Sheet open={open} onOpenChange={onOpenChange} side="left">
<AnimatePresence>
{open && (
<SheetContent className="w-full max-w-sm" showCloseButton={false}>
<SheetHeader className="min-h-0 border-b-0 px-5 py-4">
<div className="flex w-full items-center justify-between">
<SheetTitle className="text-lg font-medium">Filters</SheetTitle>
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
size="icon-sm"
aria-label="Close filters"
>
<RiCloseLine size={20} />
</Button>
</div>
</SheetHeader>
<SheetBody className="px-5">
{/* Active selections */}
{(activeChips.length > 0 || activePriceInput) && (
<div className="mb-6 flex flex-wrap items-center gap-2">
{activeChips.map((value) => (
<button
key={value.id}
onClick={() => toggleValue(value)}
className="flex items-center gap-x-1 rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
>
{value.label}
<RiCloseLine size={12} />
</button>
))}
<Button
onClick={clearAll}
variant="link"
className="h-auto px-0 text-xs font-normal text-muted-foreground"
>
Clear all
</Button>
</div>
)}
{filters.length === 0 && (
<p className="text-sm text-muted-foreground">
No filters available for these results.
</p>
)}
{/* Price */}
{priceFilter && (
<div className="mb-8">
<h3 className="mb-3 text-base text-foreground">Price</h3>
<div className="flex items-center gap-x-2">
<input
type="number"
inputMode="decimal"
value={priceMin}
onChange={(event) => setPriceMin(event.target.value)}
placeholder="$ From"
aria-label="Minimum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<span className="text-muted-foreground"></span>
<input
type="number"
inputMode="decimal"
value={priceMax}
onChange={(event) => setPriceMax(event.target.value)}
placeholder="$ To"
aria-label="Maximum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<Button
onClick={applyPrice}
size="icon"
aria-label="Apply price range"
className="shrink-0"
>
<RiCheckLine size={16} />
</Button>
</div>
</div>
)}
{/* Facets */}
{listFilters.map((filter) => (
<div key={filter.id} className="mb-8">
<h3 className="mb-3 text-base text-foreground">
{filter.label}
</h3>
{isColorFilter(filter) ? (
<div className="flex flex-wrap gap-2">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
const color = swatchColorForName(value.label);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
title={`${value.label} (${value.count})`}
aria-label={value.label}
aria-pressed={isActive}
className={`h-8 w-8 rounded-full transition-shadow ${
isActive
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
style={{ backgroundColor: color ?? undefined }}
>
{!color && (
<span className="text-[10px]">
{value.label.at(0)}
</span>
)}
</button>
);
})}
</div>
) : (
<div className="flex flex-col">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
aria-pressed={isActive}
className="flex items-center justify-between py-1.5 text-left text-sm text-foreground hover:text-muted-foreground"
>
<span>
{value.label} ({value.count})
</span>
{isActive && <RiCheckLine size={16} />}
</button>
);
})}
</div>
)}
</div>
))}
</SheetBody>
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
export default SearchFilters;
+241
View File
@@ -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;
+196
View File
@@ -0,0 +1,196 @@
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
// A command palette in the shadcn shape, implemented on this project's own
// Dialog rather than cmdk so it stays dependency-free. Filtering is left to the
// caller, which suits async sources like the Storefront API.
function Command({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="command"
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground',
className
)}
{...props}
/>
);
}
interface CommandDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
className?: string;
}
function CommandDialog({
open,
onOpenChange,
children,
className,
}: CommandDialogProps) {
// Dialog portals straight into document.body, so hold off until mounted or
// prerendering this component's page throws "document is not defined".
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
// DialogContent merges with clsx, so its own `p-6`/`gap-4` need the
// important flag to lose to these.
className={cn(
'top-24 max-w-xl translate-y-0 overflow-hidden p-0! gap-0!',
className
)}
>
<Command>{children}</Command>
</DialogContent>
</Dialog>
);
}
interface CommandInputProps
extends Omit<React.ComponentProps<'input'>, 'onChange'> {
onValueChange?: (value: string) => void;
onClear?: () => void;
onClose?: () => void;
}
function CommandInput({
className,
value,
onValueChange,
onClear,
onClose,
...props
}: CommandInputProps) {
const hasValue = Boolean(String(value ?? '').length);
return (
<div
data-slot="command-input-wrapper"
className="flex h-12 items-center gap-2 px-3"
>
<RiSearchLine size={18} className="shrink-0 text-muted-foreground" />
<input
data-slot="command-input"
value={value}
onChange={(event) => onValueChange?.(event.target.value)}
className={cn(
'flex h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground',
className
)}
{...props}
/>
{hasValue && onClear && (
<Button
type="button"
onClick={onClear}
variant="ghost"
size="sm"
className="font-normal text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
{onClose && (
<Button
type="button"
onClick={onClose}
variant="ghost"
size="icon-sm"
aria-label="Close search"
>
<RiCloseLine size={18} />
</Button>
)}
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="command-list"
className={cn('max-h-80 overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
);
}
function CommandEmpty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="command-empty"
className={cn('px-4 py-8 text-center text-sm text-muted-foreground', className)}
{...props}
/>
);
}
interface CommandGroupProps extends React.ComponentProps<'div'> {
heading?: string;
}
function CommandGroup({ className, heading, children, ...props }: CommandGroupProps) {
return (
<div
data-slot="command-group"
className={cn('px-2 py-2', className)}
{...props}
>
{heading && (
<div className="px-2 pb-2 text-xs uppercase tracking-wide text-muted-foreground">
{heading}
</div>
)}
{children}
</div>
);
}
function CommandItem({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="command-item"
role="option"
className={cn(
'flex cursor-pointer select-none items-center gap-3 rounded-md px-2 py-2 text-sm outline-none hover:bg-accent',
className
)}
{...props}
/>
);
}
function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="command-separator"
className={cn('h-px bg-border', className)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
};