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:
Rami Bitar
2026-08-01 12:27:05 -04:00
co-authored by Claude Opus 5
parent cc901b9ce8
commit 2ef5639a2e
13 changed files with 515 additions and 144 deletions
+11
View File
@@ -94,6 +94,17 @@ h2,
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 p {
margin-bottom: 1rem;
+4 -3
View File
@@ -126,10 +126,11 @@ const CartDrawer: React.FC = () => {
<Button
onClick={closeCart}
variant="ghost"
size="icon-sm"
size="icon"
aria-label="Close cart"
className="rounded-full"
>
<RiCloseLine size={20} />
<RiCloseLine className="size-5" />
</Button>
</div>
</SheetHeader>
@@ -138,7 +139,7 @@ const CartDrawer: React.FC = () => {
<SheetBody className="px-5">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader size={32} />
<Loader size={20} />
</div>
) : items.length === 0 ? (
<Empty>
+167 -49
View File
@@ -1,89 +1,207 @@
'use client';
import React from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'next/navigation';
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
import ProductCard from './product-card';
import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar';
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 =
'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 }) => (
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground mb-16">
{title}
</h2>
);
interface SortOption {
label: string;
sortKey: CollectionSortKey;
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 params = useParams();
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
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection';
if (loading) {
return (
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={formattedTitle} />
useEffect(() => {
if (!handle) return;
let cancelled = false;
{/* 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}>
{Array.from({ length: 8 }).map((_, index) => (
{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 bg-zinc-200 w-4/5"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div>
<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>
</div>
</div>
);
}
if (error) {
return (
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<CollectionTitle title={formattedTitle} />
<p className="text-sm text-muted-foreground mb-6">{error}</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
);
}
const products = collection?.products || [];
const title = collection?.title || formattedTitle;
return (
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={title} />
{products.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">
This collection doesn&apos;t have any products yet.
) : 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>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
);
};
+8 -4
View File
@@ -20,9 +20,9 @@ const CartIcon: React.FC = () => {
variant="ghost"
size="icon"
aria-label={`Open bag (${itemCount})`}
className="relative"
className="relative rounded-full"
>
<RiShoppingBagLine size={20} />
<RiShoppingBagLine className="size-5" />
{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">
{itemCount > 99 ? '99+' : itemCount}
@@ -86,7 +86,7 @@ const Header: React.FC<HeaderProps> = ({
</div>
{/* Actions */}
<div className="flex items-center gap-x-1">
<div className="flex items-center">
<SearchDialog />
<CartIcon />
@@ -98,7 +98,11 @@ const Header: React.FC<HeaderProps> = ({
className="md:hidden"
aria-label="Toggle menu"
>
{menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
{menuOpen ? (
<RiCloseLine className="size-5" />
) : (
<RiMenu3Line className="size-5" />
)}
</Button>
</div>
</div>
@@ -78,6 +78,24 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
}
}, [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 newOptions = { ...selectedOptions, [optionName]: value };
setSelectedOptions(newOptions);
@@ -197,6 +215,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange}
isOptionValueAvailable={isOptionValueAvailable}
loading={addingToCart}
buyingNow={buyingNow}
addToCartLabel={addToCartLabel}
@@ -44,6 +44,37 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
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 scroller = scrollerRef.current;
const slide = scroller?.children[index];
@@ -89,7 +120,12 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
<div
ref={scrollerRef}
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) => (
<button
@@ -103,7 +139,8 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
<img
src={image.url}
alt={image.altText || 'Product image'}
className="w-full h-full object-cover"
draggable={false}
className="w-full h-full object-cover select-none"
/>
</button>
))}
@@ -51,6 +51,8 @@ interface ProductDetailInfoProps {
handleAddToCart: () => void;
handleBuyNow?: () => 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;
buyingNow?: boolean;
addToCartLabel?: string;
@@ -81,6 +83,7 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
handleAddToCart,
handleBuyNow,
onOptionChange,
isOptionValueAvailable,
loading = false,
buyingNow = false,
addToCartLabel = 'Add to Cart',
@@ -143,6 +146,8 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
<div className="flex flex-wrap gap-2">
{optionValuesFor(option).map((value) => {
const isSelected = selected === value.name;
const isSoldOut =
isOptionValueAvailable?.(option.name, value.name) === false;
if (isSwatch) {
const { background, image } = swatchStyle(value);
@@ -153,14 +158,17 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onClick={() => onOptionChange(option.name, value.name)}
variant="ghost"
size="icon-sm"
title={value.name}
aria-label={value.name}
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 ${
isSelected
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
style={{
backgroundColor: background,
backgroundImage: image ? `url(${image})` : undefined,
@@ -179,11 +187,12 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onClick={() => onOptionChange(option.name, value.name)}
variant="outline"
aria-pressed={isSelected}
title={isSoldOut ? `${value.name} — out of stock` : undefined}
className={`min-w-14 px-5 font-normal shadow-none ${
isSelected
? 'border-foreground text-foreground'
: 'text-muted-foreground hover:border-foreground hover:text-foreground'
}`}
} ${isSoldOut ? 'option-unavailable text-muted-foreground/60' : ''}`}
>
{value.name}
</Button>
@@ -12,21 +12,36 @@ import {
import { Button } from '@/components/ui/button';
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
import { swatchColorForName } from '@/config/swatches';
import type { SearchFilter, SearchFilterValue } from '@/hooks/use-shopify-search';
// 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;
onOpenChange: (open: boolean) => void;
filters: SearchFilter[];
filters: ProductFilterFacet[];
activeFilters: string[];
onActiveFiltersChange: (filters: string[]) => void;
}
// 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');
const SearchFilters: React.FC<SearchFiltersProps> = ({
const ProductFilters: React.FC<ProductFiltersProps> = ({
open,
onOpenChange,
filters,
@@ -45,7 +60,7 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({
input.includes('"price"')
);
const toggleValue = (value: SearchFilterValue) => {
const toggleValue = (value: ProductFilterValue) => {
onActiveFiltersChange(
activeSet.has(value.input)
? activeFilters.filter((input) => input !== value.input)
@@ -235,4 +250,4 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({
);
};
export default SearchFilters;
export default ProductFilters;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
RiEqualizerLine,
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react';
export interface ToolbarSortOption {
label: string;
}
interface ProductToolbarProps {
totalCount?: number | null;
onOpenFilters: () => void;
sortOptions: ToolbarSortOption[];
sortIndex: number;
onSortChange: (index: number) => void;
activeFilterCount?: number;
}
// Filters trigger on the left, item count and sort menu on the right — shared
// by the search results and collection pages.
const ProductToolbar: React.FC<ProductToolbarProps> = ({
totalCount,
onOpenFilters,
sortOptions,
sortIndex,
onSortChange,
activeFilterCount = 0,
}) => {
const [sortOpen, setSortOpen] = useState(false);
return (
<div className="flex items-center justify-between">
<Button
onClick={onOpenFilters}
variant="ghost"
className="gap-x-2 px-0 font-normal hover:bg-transparent"
>
<RiEqualizerLine size={18} />
Filters
{activeFilterCount > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{activeFilterCount}
</span>
)}
</Button>
<div className="flex items-center gap-x-4">
{typeof totalCount === 'number' && (
<span className="text-sm text-muted-foreground tabular-nums">
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
</span>
)}
<div className="relative">
<Button
onClick={() => setSortOpen((prev) => !prev)}
variant="ghost"
aria-expanded={sortOpen}
className="gap-x-1 px-0 font-normal hover:bg-transparent"
>
Sort
<RiArrowDownSLine size={16} />
</Button>
{sortOpen && (
<>
{/* Click-away layer sits under the menu, above the page. */}
<div
className="fixed inset-0 z-40"
onClick={() => setSortOpen(false)}
/>
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
{sortOptions.map((option, index) => (
<button
key={option.label}
onClick={() => {
onSortChange(index);
setSortOpen(false);
}}
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
{option.label}
{index === sortIndex && <RiCheckLine size={16} />}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};
export default ProductToolbar;
+2 -1
View File
@@ -86,8 +86,9 @@ const SearchDialog: React.FC = () => {
variant="ghost"
size="icon"
aria-label="Search"
className="rounded-full"
>
<RiSearchLine size={20} />
<RiSearchLine className="size-5" />
</Button>
<CommandDialog open={open} onOpenChange={(next) => !next && close()}>
+11 -55
View File
@@ -3,10 +3,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
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 { Loader } from '@/app/components/ui/loader';
import { RiEqualizerLine, RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
import {
searchProducts,
type SearchFilter,
@@ -42,7 +42,6 @@ const SearchResults: React.FC = () => {
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0);
const [sortOpen, setSortOpen] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
@@ -126,58 +125,15 @@ const SearchResults: React.FC = () => {
Search
</h1>
{/* Toolbar */}
<div className="mt-6 flex items-center justify-between">
<Button
onClick={() => setFiltersOpen(true)}
variant="ghost"
className="gap-x-2 px-0 font-normal hover:bg-transparent"
>
<RiEqualizerLine size={18} />
Filters
</Button>
<div className="flex items-center gap-x-4">
<span className="text-sm text-muted-foreground tabular-nums">
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
</span>
<div className="relative">
<Button
onClick={() => setSortOpen((prev) => !prev)}
variant="ghost"
aria-expanded={sortOpen}
className="gap-x-1 px-0 font-normal hover:bg-transparent"
>
Sort
<RiArrowDownSLine size={16} />
</Button>
{sortOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setSortOpen(false)}
<div className="mt-6">
<ProductToolbar
totalCount={totalCount}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
{SORT_OPTIONS.map((option, index) => (
<button
key={option.label}
onClick={() => {
setSortIndex(index);
setSortOpen(false);
}}
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
{option.label}
{index === sortIndex && <RiCheckLine size={16} />}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
{/* Results */}
@@ -227,7 +183,7 @@ const SearchResults: React.FC = () => {
</div>
</div>
<SearchFilters
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
+26 -2
View File
@@ -31,7 +31,14 @@ export const GET_COLLECTIONS_QUERY = `
// Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = `
${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) {
id
title
@@ -45,7 +52,24 @@ export const GET_COLLECTION_PRODUCTS_QUERY = `
width
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 {
node {
...ProductFragment
+82 -6
View File
@@ -26,10 +26,40 @@ export interface CollectionWithProducts extends Collection {
products: Product[];
}
export type CollectionSortKey =
| 'COLLECTION_DEFAULT'
| 'BEST_SELLING'
| 'CREATED'
| 'PRICE'
| 'TITLE';
interface UseCollectionProductsOptions {
first?: number;
sortKey?: 'BEST_SELLING' | 'CREATED' | 'PRICE' | 'TITLE';
after?: string | null;
sortKey?: CollectionSortKey;
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
@@ -45,19 +75,65 @@ export async function getCollections(first = 50): Promise<Collection[]> {
// Fetch products in a collection by handle
export async function getCollectionProducts(
handle: string,
{ first = 50, sortKey = 'BEST_SELLING', reverse = false }: UseCollectionProductsOptions = {}
options: UseCollectionProductsOptions = {}
): 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({
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;
if (!collection) return null;
if (!collection) {
return {
collection: null,
products: [],
filters: [],
hasNextPage: false,
endCursor: null,
};
}
const { edges, pageInfo, filters: facets } = collection.products;
return {
...collection,
products: collection.products.edges.map((edge: { node: Product }) => edge.node),
collection,
products: edges.map((edge: { node: Product }) => edge.node),
filters: facets ?? [],
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}