Template
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc901b9ce8
commit
2ef5639a2e
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
<div className={GRID_CLASSES}>
|
try {
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
setLoading(true);
|
||||||
<div key={index} className="animate-pulse">
|
setError(null);
|
||||||
<div className="aspect-square bg-zinc-100"></div>
|
|
||||||
<div className="pt-4 space-y-2">
|
|
||||||
<div className="h-4 bg-zinc-200 w-4/5"></div>
|
|
||||||
<div className="h-4 bg-zinc-200 w-1/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
const page = await getCollectionProductsPage(handle, {
|
||||||
return (
|
first: PAGE_SIZE,
|
||||||
<div className="py-16 bg-background">
|
sortKey: sort.sortKey,
|
||||||
<div className="max-w-screen-2xl mx-auto px-8 text-center">
|
reverse: sort.reverse,
|
||||||
<CollectionTitle title={formattedTitle} />
|
filterInputs: activeFilters,
|
||||||
<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 || [];
|
if (cancelled) return;
|
||||||
const title = collection?.title || formattedTitle;
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="py-16 bg-background">
|
<section className="bg-background py-10">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8">
|
<div className="max-w-screen-2xl mx-auto px-8">
|
||||||
<CollectionTitle title={title} />
|
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||||
|
{title || formattedTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
{products.length === 0 ? (
|
<div className="mt-6">
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<ProductToolbar
|
||||||
This collection doesn't have any products yet.
|
totalCount={products.length > 0 ? products.length : null}
|
||||||
</p>
|
onOpenFilters={() => setFiltersOpen(true)}
|
||||||
) : (
|
sortOptions={SORT_OPTIONS}
|
||||||
<div className={GRID_CLASSES}>
|
sortIndex={sortIndex}
|
||||||
{products.map((product) => (
|
onSortChange={setSortIndex}
|
||||||
<ProductCard key={product.id} product={product} />
|
activeFilterCount={activeFilters.length}
|
||||||
))}
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{loading ? (
|
||||||
|
<div className={GRID_CLASSES}>
|
||||||
|
{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">
|
||||||
|
{activeFilters.length > 0
|
||||||
|
? 'No products matched your filters.'
|
||||||
|
: "This collection doesn't have any products yet."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={GRID_CLASSES}>
|
||||||
|
{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>
|
</div>
|
||||||
</div>
|
|
||||||
|
<ProductFilters
|
||||||
|
open={filtersOpen}
|
||||||
|
onOpenChange={setFiltersOpen}
|
||||||
|
filters={filters}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
onActiveFiltersChange={setActiveFilters}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,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}
|
||||||
@@ -86,7 +86,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center">
|
||||||
<SearchDialog />
|
<SearchDialog />
|
||||||
<CartIcon />
|
<CartIcon />
|
||||||
|
|
||||||
@@ -98,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>
|
||||||
|
|||||||
@@ -12,21 +12,36 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
|
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
|
||||||
import { swatchColorForName } from '@/config/swatches';
|
import { swatchColorForName } from '@/config/swatches';
|
||||||
import type { SearchFilter, SearchFilterValue } from '@/hooks/use-shopify-search';
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
interface SearchFiltersProps {
|
export interface ProductFilterFacet {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
|
||||||
|
values: ProductFilterValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductFiltersProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
filters: SearchFilter[];
|
filters: ProductFilterFacet[];
|
||||||
activeFilters: string[];
|
activeFilters: string[];
|
||||||
onActiveFiltersChange: (filters: string[]) => void;
|
onActiveFiltersChange: (filters: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colour facets render as swatches; everything else as a labelled list.
|
// Colour facets render as swatches; everything else as a labelled list.
|
||||||
const isColorFilter = (filter: SearchFilter) =>
|
const isColorFilter = (filter: ProductFilterFacet) =>
|
||||||
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
|
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
|
||||||
|
|
||||||
const SearchFilters: React.FC<SearchFiltersProps> = ({
|
const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
filters,
|
filters,
|
||||||
@@ -45,7 +60,7 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({
|
|||||||
input.includes('"price"')
|
input.includes('"price"')
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleValue = (value: SearchFilterValue) => {
|
const toggleValue = (value: ProductFilterValue) => {
|
||||||
onActiveFiltersChange(
|
onActiveFiltersChange(
|
||||||
activeSet.has(value.input)
|
activeSet.has(value.input)
|
||||||
? activeFilters.filter((input) => input !== value.input)
|
? activeFilters.filter((input) => input !== value.input)
|
||||||
@@ -235,4 +250,4 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SearchFilters;
|
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;
|
||||||
@@ -86,8 +86,9 @@ const SearchDialog: React.FC = () => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
|
className="rounded-full"
|
||||||
>
|
>
|
||||||
<RiSearchLine size={20} />
|
<RiSearchLine className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<CommandDialog open={open} onOpenChange={(next) => !next && close()}>
|
<CommandDialog open={open} onOpenChange={(next) => !next && close()}>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import ProductCard from './product-card';
|
import ProductCard from './product-card';
|
||||||
import SearchFilters from './search-filters';
|
import ProductFilters 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 { Loader } from '@/app/components/ui/loader';
|
||||||
import { RiEqualizerLine, RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
|
|
||||||
import {
|
import {
|
||||||
searchProducts,
|
searchProducts,
|
||||||
type SearchFilter,
|
type SearchFilter,
|
||||||
@@ -42,7 +42,6 @@ const SearchResults: React.FC = () => {
|
|||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
const [hasNextPage, setHasNextPage] = useState(false);
|
||||||
|
|
||||||
const [sortIndex, setSortIndex] = useState(0);
|
const [sortIndex, setSortIndex] = useState(0);
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -126,58 +125,15 @@ const SearchResults: React.FC = () => {
|
|||||||
Search
|
Search
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Toolbar */}
|
<div className="mt-6">
|
||||||
<div className="mt-6 flex items-center justify-between">
|
<ProductToolbar
|
||||||
<Button
|
totalCount={totalCount}
|
||||||
onClick={() => setFiltersOpen(true)}
|
onOpenFilters={() => setFiltersOpen(true)}
|
||||||
variant="ghost"
|
sortOptions={SORT_OPTIONS}
|
||||||
className="gap-x-2 px-0 font-normal hover:bg-transparent"
|
sortIndex={sortIndex}
|
||||||
>
|
onSortChange={setSortIndex}
|
||||||
<RiEqualizerLine size={18} />
|
activeFilterCount={activeFilters.length}
|
||||||
Filters
|
/>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
|
||||||
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
onClick={() => setSortOpen((prev) => !prev)}
|
|
||||||
variant="ghost"
|
|
||||||
aria-expanded={sortOpen}
|
|
||||||
className="gap-x-1 px-0 font-normal hover:bg-transparent"
|
|
||||||
>
|
|
||||||
Sort
|
|
||||||
<RiArrowDownSLine size={16} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{sortOpen && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40"
|
|
||||||
onClick={() => setSortOpen(false)}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
|
|
||||||
{SORT_OPTIONS.map((option, index) => (
|
|
||||||
<button
|
|
||||||
key={option.label}
|
|
||||||
onClick={() => {
|
|
||||||
setSortIndex(index);
|
|
||||||
setSortOpen(false);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
{index === sortIndex && <RiCheckLine size={16} />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
@@ -227,7 +183,7 @@ const SearchResults: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchFilters
|
<ProductFilters
|
||||||
open={filtersOpen}
|
open={filtersOpen}
|
||||||
onOpenChange={setFiltersOpen}
|
onOpenChange={setFiltersOpen}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user