Template
- 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
209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { useParams } from 'next/navigation';
|
|
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-4 xl:grid-cols-5 gap-x-8 gap-y-12';
|
|
|
|
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 [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);
|
|
|
|
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';
|
|
|
|
useEffect(() => {
|
|
if (!handle) return;
|
|
let cancelled = false;
|
|
|
|
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: 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>
|
|
|
|
<ProductFilters
|
|
open={filtersOpen}
|
|
onOpenChange={setFiltersOpen}
|
|
filters={filters}
|
|
activeFilters={activeFilters}
|
|
onActiveFiltersChange={setActiveFilters}
|
|
/>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default CollectionDetail;
|