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
+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,
};