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,33 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
import Header from '@/components/shopify/header';
|
||||||
|
import Footer from '@/components/shopify/footer';
|
||||||
|
import SearchResults from '@/components/shopify/search-results';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Search — Shop',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
storeName="Shop"
|
||||||
|
logoUrl=""
|
||||||
|
links={[
|
||||||
|
{ label: 'Products', url: '/' },
|
||||||
|
{ label: 'Collections', url: '/collections' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* useSearchParams needs a Suspense boundary to prerender this route. */}
|
||||||
|
<Suspense fallback={<div className="py-10" />}>
|
||||||
|
<SearchResults />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
storeName="Shop"
|
||||||
|
copyright="© 2026 Shop. All rights reserved."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useCartStore } from '@/hooks/use-shopify-cart';
|
import { useCartStore } from '@/hooks/use-shopify-cart';
|
||||||
import CartDrawer from '@/components/shopify/cart-drawer';
|
import CartDrawer from '@/components/shopify/cart-drawer';
|
||||||
|
import SearchDialog from '@/components/shopify/search-dialog';
|
||||||
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-1">
|
||||||
|
<SearchDialog />
|
||||||
<CartIcon />
|
<CartIcon />
|
||||||
|
|
||||||
{/* Mobile hamburger */}
|
{/* Mobile hamburger */}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { ProductFragment } from '@/graphql/products';
|
||||||
|
|
||||||
|
// Storefront search over products. `productFilters` accepts the raw `input`
|
||||||
|
// values returned in `productFilters[].values[].input`, so facets round-trip
|
||||||
|
// without the client needing to know each filter's shape.
|
||||||
|
export const SEARCH_PRODUCTS_QUERY = `
|
||||||
|
${ProductFragment}
|
||||||
|
query SearchProducts(
|
||||||
|
$query: String!
|
||||||
|
$first: Int!
|
||||||
|
$after: String
|
||||||
|
$sortKey: SearchSortKeys
|
||||||
|
$reverse: Boolean
|
||||||
|
$productFilters: [ProductFilter!]
|
||||||
|
) {
|
||||||
|
search(
|
||||||
|
query: $query
|
||||||
|
first: $first
|
||||||
|
after: $after
|
||||||
|
types: PRODUCT
|
||||||
|
sortKey: $sortKey
|
||||||
|
reverse: $reverse
|
||||||
|
productFilters: $productFilters
|
||||||
|
) {
|
||||||
|
totalCount
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
productFilters {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
type
|
||||||
|
values {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
count
|
||||||
|
input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
... on Product {
|
||||||
|
...ProductFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Lightweight variant for the autocomplete dropdown — just enough to render a
|
||||||
|
// row, so the dialog stays responsive while typing.
|
||||||
|
export const SEARCH_SUGGESTIONS_QUERY = `
|
||||||
|
query SearchSuggestions($query: String!, $first: Int!) {
|
||||||
|
search(query: $query, first: $first, types: PRODUCT) {
|
||||||
|
totalCount
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
... on Product {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
handle
|
||||||
|
featuredImage {
|
||||||
|
url
|
||||||
|
altText
|
||||||
|
}
|
||||||
|
priceRange {
|
||||||
|
minVariantPrice {
|
||||||
|
amount
|
||||||
|
currencyCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { shopifyFetch } from '@/services/shopify/client';
|
||||||
|
import {
|
||||||
|
SEARCH_PRODUCTS_QUERY,
|
||||||
|
SEARCH_SUGGESTIONS_QUERY,
|
||||||
|
} from '@/graphql/search';
|
||||||
|
import type { Product } from '@/hooks/use-shopify-products';
|
||||||
|
|
||||||
|
export type SearchSortKey = 'RELEVANCE' | 'PRICE';
|
||||||
|
|
||||||
|
export interface SearchFilterValue {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
/** JSON string accepted back as a `ProductFilter` input. */
|
||||||
|
input: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchFilter {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
|
||||||
|
values: SearchFilterValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchProductsResult {
|
||||||
|
products: Product[];
|
||||||
|
totalCount: number;
|
||||||
|
filters: SearchFilter[];
|
||||||
|
hasNextPage: boolean;
|
||||||
|
endCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchSuggestion {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
handle: string;
|
||||||
|
featuredImage?: {
|
||||||
|
url: string;
|
||||||
|
altText?: string;
|
||||||
|
} | null;
|
||||||
|
priceRange: {
|
||||||
|
minVariantPrice: {
|
||||||
|
amount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchProductsOptions {
|
||||||
|
query: string;
|
||||||
|
first?: number;
|
||||||
|
after?: string | null;
|
||||||
|
sortKey?: SearchSortKey;
|
||||||
|
reverse?: boolean;
|
||||||
|
/** Raw `input` strings from the facets, parsed back into filter objects. */
|
||||||
|
filterInputs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFilterInputs(inputs: string[]): unknown[] {
|
||||||
|
return inputs.flatMap((input) => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(input)];
|
||||||
|
} catch {
|
||||||
|
console.warn('Ignoring malformed product filter input:', input);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchProducts({
|
||||||
|
query,
|
||||||
|
first = 24,
|
||||||
|
after = null,
|
||||||
|
sortKey = 'RELEVANCE',
|
||||||
|
reverse = false,
|
||||||
|
filterInputs = [],
|
||||||
|
}: SearchProductsOptions): Promise<SearchProductsResult> {
|
||||||
|
const response = await shopifyFetch({
|
||||||
|
query: SEARCH_PRODUCTS_QUERY,
|
||||||
|
variables: {
|
||||||
|
query,
|
||||||
|
first,
|
||||||
|
after,
|
||||||
|
sortKey,
|
||||||
|
reverse,
|
||||||
|
productFilters: filterInputs.length
|
||||||
|
? parseFilterInputs(filterInputs)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = response.data.search;
|
||||||
|
|
||||||
|
return {
|
||||||
|
products: search.edges.map((edge: { node: Product }) => edge.node),
|
||||||
|
totalCount: search.totalCount ?? 0,
|
||||||
|
filters: search.productFilters ?? [],
|
||||||
|
hasNextPage: Boolean(search.pageInfo?.hasNextPage),
|
||||||
|
endCursor: search.pageInfo?.endCursor ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSuggestions(
|
||||||
|
query: string,
|
||||||
|
first = 3
|
||||||
|
): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
|
||||||
|
const response = await shopifyFetch({
|
||||||
|
query: SEARCH_SUGGESTIONS_QUERY,
|
||||||
|
variables: { query, first },
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = response.data.search;
|
||||||
|
|
||||||
|
return {
|
||||||
|
products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node),
|
||||||
|
totalCount: search.totalCount ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user