Template
Compare commits
2
Commits
e04f1e0405
...
2ef5639a2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef5639a2e | ||
|
|
cc901b9ce8 |
@@ -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;
|
||||||
|
|||||||
@@ -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."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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