Compare commits

...
2 Commits
Author SHA1 Message Date
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
Rami BitarandClaude Opus 5 cc901b9ce8 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
2026-08-01 12:15:49 -04:00
17 changed files with 1535 additions and 80 deletions
+11
View File
@@ -94,6 +94,17 @@ h2,
display: none; display: none;
} }
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
.option-unavailable {
background-image: linear-gradient(
to top right,
transparent calc(50% - 0.5px),
currentColor calc(50% - 0.5px),
currentColor calc(50% + 0.5px),
transparent calc(50% + 0.5px)
);
}
/* Product description (HTML returned by the Storefront API) */ /* Product description (HTML returned by the Storefront API) */
.product-description p { .product-description p {
margin-bottom: 1rem; margin-bottom: 1rem;
+33
View File
@@ -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 -3
View File
@@ -126,10 +126,11 @@ const CartDrawer: React.FC = () => {
<Button <Button
onClick={closeCart} onClick={closeCart}
variant="ghost" variant="ghost"
size="icon-sm" size="icon"
aria-label="Close cart" aria-label="Close cart"
className="rounded-full"
> >
<RiCloseLine size={20} /> <RiCloseLine className="size-5" />
</Button> </Button>
</div> </div>
</SheetHeader> </SheetHeader>
@@ -138,7 +139,7 @@ const CartDrawer: React.FC = () => {
<SheetBody className="px-5"> <SheetBody className="px-5">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader size={32} /> <Loader size={20} />
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Empty> <Empty>
+167 -49
View File
@@ -1,89 +1,207 @@
'use client'; 'use client';
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
import ProductCard from './product-card'; import ProductCard from './product-card';
import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import {
getCollectionProductsPage,
type CollectionSortKey,
} from '@/hooks/use-shopify-collections';
import type { Product } from '@/hooks/use-shopify-products';
const PAGE_SIZE = 24;
const GRID_CLASSES = const GRID_CLASSES =
'grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16'; 'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
const CollectionTitle: React.FC<{ title: string }> = ({ title }) => ( interface SortOption {
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground mb-16"> label: string;
{title} sortKey: CollectionSortKey;
</h2> reverse: boolean;
); }
const SORT_OPTIONS: SortOption[] = [
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
];
const CollectionDetail: React.FC = () => { const CollectionDetail: React.FC = () => {
const params = useParams(); const params = useParams();
const handle = params?.handle as string; const handle = params?.handle as string;
const { collection, loading, error, refetch } = useCollectionProducts(handle); const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
const [title, setTitle] = useState<string | null>(null);
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);
// Format title from handle const [sortIndex, setSortIndex] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Fall back to the handle until the collection's real title arrives.
const formattedTitle = handle const formattedTitle = handle
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) ? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection'; : 'Collection';
if (loading) { useEffect(() => {
return ( if (!handle) return;
<div className="py-16 bg-background"> let cancelled = false;
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={formattedTitle} />
{/* Loading Skeleton */} const run = async () => {
try {
setLoading(true);
setError(null);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
if (!page.collection) {
setError('Collection not found');
return;
}
setTitle(page.collection.title);
setProducts(page.products);
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
// Keep the facet list stable while a selection is active, so options
// don't disappear out from under the panel.
if (activeFilters.length === 0) setFilters(page.filters);
} catch (err) {
if (cancelled) return;
console.error('Error fetching collection products:', err);
setError(err instanceof Error ? err.message : 'Failed to load collection');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [handle, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const page = await getCollectionProductsPage(handle, {
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, ...page.products.filter((p) => !seen.has(p.id))];
});
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
} catch (err) {
console.error('Failed to load more products:', 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">
{title || formattedTitle}
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={products.length > 0 ? products.length : null}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
<div className="mt-8">
{loading ? (
<div className={GRID_CLASSES}> <div className={GRID_CLASSES}>
{Array.from({ length: 8 }).map((_, index) => ( {Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse"> <div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div> <div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2"> <div className="pt-4 space-y-2">
<div className="h-4 bg-zinc-200 w-4/5"></div> <div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div> <div className="h-4 w-1/4 bg-zinc-200"></div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> ) : error ? (
</div> <p className="text-sm text-muted-foreground">{error}</p>
); ) : products.length === 0 ? (
} <p className="text-sm text-muted-foreground">
{activeFilters.length > 0
if (error) { ? 'No products matched your filters.'
return ( : "This collection doesn't have any products yet."}
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<CollectionTitle title={formattedTitle} />
<p className="text-sm text-muted-foreground mb-6">{error}</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
);
}
const products = collection?.products || [];
const title = collection?.title || formattedTitle;
return (
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={title} />
{products.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">
This collection doesn&apos;t have any products yet.
</p> </p>
) : ( ) : (
<>
<div className={GRID_CLASSES}> <div className={GRID_CLASSES}>
{products.map((product) => ( {products.map((product) => (
<ProductCard key={product.id} product={product} /> <ProductCard key={product.id} product={product} />
))} ))}
</div> </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>
</div> </div>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
); );
}; };
+10 -4
View File
@@ -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';
@@ -19,9 +20,9 @@ const CartIcon: React.FC = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={`Open bag (${itemCount})`} aria-label={`Open bag (${itemCount})`}
className="relative" className="relative rounded-full"
> >
<RiShoppingBagLine size={20} /> <RiShoppingBagLine className="size-5" />
{itemCount > 0 && ( {itemCount > 0 && (
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center"> <span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center">
{itemCount > 99 ? '99+' : itemCount} {itemCount > 99 ? '99+' : itemCount}
@@ -85,7 +86,8 @@ const Header: React.FC<HeaderProps> = ({
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-x-1"> <div className="flex items-center">
<SearchDialog />
<CartIcon /> <CartIcon />
{/* Mobile hamburger */} {/* Mobile hamburger */}
@@ -96,7 +98,11 @@ const Header: React.FC<HeaderProps> = ({
className="md:hidden" className="md:hidden"
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />} {menuOpen ? (
<RiCloseLine className="size-5" />
) : (
<RiMenu3Line className="size-5" />
)}
</Button> </Button>
</div> </div>
</div> </div>
@@ -78,6 +78,24 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
} }
}, [product]); }, [product]);
// A value is available if some in-stock variant carries it alongside the
// other currently-selected options. Options the shopper hasn't chosen yet
// act as wildcards, so nothing is struck through before a full selection.
const isOptionValueAvailable = (optionName: string, value: string) => {
const variants = product?.variants.edges ?? [];
if (variants.length === 0) return true;
return variants.some(({ node }) => {
if (!node.availableForSale) return false;
return node.selectedOptions.every((option) => {
if (option.name === optionName) return option.value === value;
const selected = selectedOptions[option.name];
return selected === undefined || selected === option.value;
});
});
};
const handleOptionChange = (optionName: string, value: string) => { const handleOptionChange = (optionName: string, value: string) => {
const newOptions = { ...selectedOptions, [optionName]: value }; const newOptions = { ...selectedOptions, [optionName]: value };
setSelectedOptions(newOptions); setSelectedOptions(newOptions);
@@ -197,6 +215,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
handleAddToCart={handleAddToCart} handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow} handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange} onOptionChange={handleOptionChange}
isOptionValueAvailable={isOptionValueAvailable}
loading={addingToCart} loading={addingToCart}
buyingNow={buyingNow} buyingNow={buyingNow}
addToCartLabel={addToCartLabel} addToCartLabel={addToCartLabel}
@@ -44,6 +44,37 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
setActiveIndex(nearest); setActiveIndex(nearest);
}, []); }, []);
// Touch already scrolls natively; this adds click-and-drag for pointers that
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'touch') return;
const scroller = scrollerRef.current;
if (!scroller) return;
drag.current = { startX: event.clientX, startScroll: scroller.scrollLeft };
scroller.style.scrollSnapType = 'none';
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
event.preventDefault();
scroller.scrollLeft =
drag.current.startScroll - (event.clientX - drag.current.startX);
};
const endDrag = () => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
drag.current = null;
// Restoring snap lets the browser settle on the nearest slide.
scroller.style.scrollSnapType = '';
};
const scrollToIndex = (index: number) => { const scrollToIndex = (index: number) => {
const scroller = scrollerRef.current; const scroller = scrollerRef.current;
const slide = scroller?.children[index]; const slide = scroller?.children[index];
@@ -89,7 +120,12 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
<div <div
ref={scrollerRef} ref={scrollerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar sm:grid sm:grid-cols-2 sm:overflow-visible" onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerCancel={endDrag}
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar touch-pan-x sm:grid sm:grid-cols-2 sm:touch-auto sm:overflow-visible"
> >
{images.map((image, index) => ( {images.map((image, index) => (
<button <button
@@ -103,7 +139,8 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
<img <img
src={image.url} src={image.url}
alt={image.altText || 'Product image'} alt={image.altText || 'Product image'}
className="w-full h-full object-cover" draggable={false}
className="w-full h-full object-cover select-none"
/> />
</button> </button>
))} ))}
@@ -51,6 +51,8 @@ interface ProductDetailInfoProps {
handleAddToCart: () => void; handleAddToCart: () => void;
handleBuyNow?: () => void; handleBuyNow?: () => void;
onOptionChange: (optionName: string, value: string) => void; onOptionChange: (optionName: string, value: string) => void;
/** Whether an option value still has an in-stock variant behind it. */
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
loading?: boolean; loading?: boolean;
buyingNow?: boolean; buyingNow?: boolean;
addToCartLabel?: string; addToCartLabel?: string;
@@ -81,6 +83,7 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
handleAddToCart, handleAddToCart,
handleBuyNow, handleBuyNow,
onOptionChange, onOptionChange,
isOptionValueAvailable,
loading = false, loading = false,
buyingNow = false, buyingNow = false,
addToCartLabel = 'Add to Cart', addToCartLabel = 'Add to Cart',
@@ -143,6 +146,8 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{optionValuesFor(option).map((value) => { {optionValuesFor(option).map((value) => {
const isSelected = selected === value.name; const isSelected = selected === value.name;
const isSoldOut =
isOptionValueAvailable?.(option.name, value.name) === false;
if (isSwatch) { if (isSwatch) {
const { background, image } = swatchStyle(value); const { background, image } = swatchStyle(value);
@@ -153,14 +158,17 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onClick={() => onOptionChange(option.name, value.name)} onClick={() => onOptionChange(option.name, value.name)}
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
title={value.name}
aria-label={value.name} aria-label={value.name}
aria-pressed={isSelected} aria-pressed={isSelected}
title={
isSoldOut ? `${value.name} — out of stock` : value.name
}
data-available={!isSoldOut}
className={`rounded-full bg-cover bg-center hover:bg-transparent ${ className={`rounded-full bg-cover bg-center hover:bg-transparent ${
isSelected isSelected
? 'ring-2 ring-foreground ring-offset-2' ? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40' : 'ring-1 ring-border hover:ring-foreground/40'
}`} } ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
style={{ style={{
backgroundColor: background, backgroundColor: background,
backgroundImage: image ? `url(${image})` : undefined, backgroundImage: image ? `url(${image})` : undefined,
@@ -179,11 +187,12 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onClick={() => onOptionChange(option.name, value.name)} onClick={() => onOptionChange(option.name, value.name)}
variant="outline" variant="outline"
aria-pressed={isSelected} aria-pressed={isSelected}
title={isSoldOut ? `${value.name} — out of stock` : undefined}
className={`min-w-14 px-5 font-normal shadow-none ${ className={`min-w-14 px-5 font-normal shadow-none ${
isSelected isSelected
? 'border-foreground text-foreground' ? 'border-foreground text-foreground'
: 'text-muted-foreground hover:border-foreground hover:text-foreground' : 'text-muted-foreground hover:border-foreground hover:text-foreground'
}`} } ${isSoldOut ? 'option-unavailable text-muted-foreground/60' : ''}`}
> >
{value.name} {value.name}
</Button> </Button>
+253
View File
@@ -0,0 +1,253 @@
'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';
// Shape of a Storefront facet — identical for `search.productFilters` and
// `collection.products.filters`, so both pages share this panel.
export interface ProductFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: ProductFilterValue[];
}
interface ProductFiltersProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: ProductFilterFacet[];
activeFilters: string[];
onActiveFiltersChange: (filters: string[]) => void;
}
// Colour facets render as swatches; everything else as a labelled list.
const isColorFilter = (filter: ProductFilterFacet) =>
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
const ProductFilters: React.FC<ProductFiltersProps> = ({
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: ProductFilterValue) => {
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 ProductFilters;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
RiEqualizerLine,
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react';
export interface ToolbarSortOption {
label: string;
}
interface ProductToolbarProps {
totalCount?: number | null;
onOpenFilters: () => void;
sortOptions: ToolbarSortOption[];
sortIndex: number;
onSortChange: (index: number) => void;
activeFilterCount?: number;
}
// Filters trigger on the left, item count and sort menu on the right — shared
// by the search results and collection pages.
const ProductToolbar: React.FC<ProductToolbarProps> = ({
totalCount,
onOpenFilters,
sortOptions,
sortIndex,
onSortChange,
activeFilterCount = 0,
}) => {
const [sortOpen, setSortOpen] = useState(false);
return (
<div className="flex items-center justify-between">
<Button
onClick={onOpenFilters}
variant="ghost"
className="gap-x-2 px-0 font-normal hover:bg-transparent"
>
<RiEqualizerLine size={18} />
Filters
{activeFilterCount > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{activeFilterCount}
</span>
)}
</Button>
<div className="flex items-center gap-x-4">
{typeof totalCount === 'number' && (
<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 && (
<>
{/* Click-away layer sits under the menu, above the page. */}
<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">
{sortOptions.map((option, index) => (
<button
key={option.label}
onClick={() => {
onSortChange(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>
);
};
export default ProductToolbar;
+176
View File
@@ -0,0 +1,176 @@
'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;
+197
View File
@@ -0,0 +1,197 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import ProductCard from './product-card';
import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
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 [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>
<div className="mt-6">
<ProductToolbar
totalCount={totalCount}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</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>
<ProductFilters
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,
};
+26 -2
View File
@@ -31,7 +31,14 @@ export const GET_COLLECTIONS_QUERY = `
// Get products in a collection // Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = ` export const GET_COLLECTION_PRODUCTS_QUERY = `
${ProductFragment} ${ProductFragment}
query GetCollectionProducts($handle: String!, $first: Int!, $sortKey: ProductCollectionSortKeys, $reverse: Boolean) { query GetCollectionProducts(
$handle: String!
$first: Int!
$after: String
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
$filters: [ProductFilter!]
) {
collection(handle: $handle) { collection(handle: $handle) {
id id
title title
@@ -45,7 +52,24 @@ export const GET_COLLECTION_PRODUCTS_QUERY = `
width width
height height
} }
products(first: $first, sortKey: $sortKey, reverse: $reverse) { products(
first: $first
after: $after
sortKey: $sortKey
reverse: $reverse
filters: $filters
) {
filters {
id
label
type
values {
id
label
count
input
}
}
edges { edges {
node { node {
...ProductFragment ...ProductFragment
+79
View File
@@ -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
}
}
}
}
}
}
}
`;
+82 -6
View File
@@ -26,10 +26,40 @@ export interface CollectionWithProducts extends Collection {
products: Product[]; products: Product[];
} }
export type CollectionSortKey =
| 'COLLECTION_DEFAULT'
| 'BEST_SELLING'
| 'CREATED'
| 'PRICE'
| 'TITLE';
interface UseCollectionProductsOptions { interface UseCollectionProductsOptions {
first?: number; first?: number;
sortKey?: 'BEST_SELLING' | 'CREATED' | 'PRICE' | 'TITLE'; after?: string | null;
sortKey?: CollectionSortKey;
reverse?: boolean; reverse?: boolean;
/** Raw `input` strings from the connection's `filters` facets. */
filterInputs?: string[];
}
export interface CollectionProductsPage {
collection: Collection | null;
products: Product[];
filters: ProductFilterFacet[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: Array<{
id: string;
label: string;
count: number;
input: string;
}>;
} }
// Fetch all collections // Fetch all collections
@@ -45,19 +75,65 @@ export async function getCollections(first = 50): Promise<Collection[]> {
// Fetch products in a collection by handle // Fetch products in a collection by handle
export async function getCollectionProducts( export async function getCollectionProducts(
handle: string, handle: string,
{ first = 50, sortKey = 'BEST_SELLING', reverse = false }: UseCollectionProductsOptions = {} options: UseCollectionProductsOptions = {}
): Promise<CollectionWithProducts | null> { ): Promise<CollectionWithProducts | null> {
const page = await getCollectionProductsPage(handle, options);
if (!page.collection) return null;
return { ...page.collection, products: page.products };
}
// Same fetch, but keeps the cursor and facet list for filtering and paging.
export async function getCollectionProductsPage(
handle: string,
{
first = 50,
after = null,
sortKey = 'COLLECTION_DEFAULT',
reverse = false,
filterInputs = [],
}: UseCollectionProductsOptions = {}
): Promise<CollectionProductsPage> {
const filters = filterInputs.flatMap((input) => {
try {
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
const response = await shopifyFetch({ const response = await shopifyFetch({
query: GET_COLLECTION_PRODUCTS_QUERY, query: GET_COLLECTION_PRODUCTS_QUERY,
variables: { handle, first, sortKey, reverse }, variables: {
handle,
first,
after,
sortKey,
reverse,
filters: filters.length ? filters : null,
},
}); });
const collection = response.data.collection; const collection = response.data.collection;
if (!collection) return null; if (!collection) {
return {
collection: null,
products: [],
filters: [],
hasNextPage: false,
endCursor: null,
};
}
const { edges, pageInfo, filters: facets } = collection.products;
return { return {
...collection, collection,
products: collection.products.edges.map((edge: { node: Product }) => edge.node), products: edges.map((edge: { node: Product }) => edge.node),
filters: facets ?? [],
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
}; };
} }
+120
View File
@@ -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,
};
}