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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user