Files
shopify-template/components/shopify/product-detail/product-detail-info.tsx
T
Rami BitarandClaude Opus 5 b139b30273 Hide the synthetic Default Title option on single-SKU products
Shopify gives every single-variant product one option named Title with the
lone value Default Title. It isn't a real choice, so filter it out of the
product detail options, the cart line summaries, and the payload the store
assistant sees. Variant matching still reads the raw selectedOptions, so
single-SKU products keep resolving their variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuRVxuyMUTzWKXW8P5Tn8A
2026-08-04 13:20:47 -04:00

273 lines
9.1 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
import { isDefaultTitleOption } from '@/services/shopify/catalog';
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
}
interface Product {
id: string;
title: string;
description?: string;
descriptionHtml?: string;
handle: string;
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
options: ProductOption[];
}
export interface ProductFeature {
icon: string;
label: string;
}
interface ProductDetailInfoProps {
product: Product;
selectedVariant: ProductVariant | null;
selectedOptions: Record<string, string>;
quantity: number;
setQuantity: (quantity: number) => void;
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;
}
// A swatch comes from the option value's own swatch (colour or image) or the
// colour its name implies (see config/swatches) — never a variant photo.
const swatchStyle = (
value: ProductOptionValue
): { background?: string; image?: string } => {
if (value.swatch?.color) return { background: value.swatch.color };
const swatchImage = value.swatch?.image?.previewImage?.url;
if (swatchImage) return { image: swatchImage };
const namedColor = swatchColorForName(value.name);
if (namedColor) return { background: namedColor };
return {};
};
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
product,
selectedVariant,
selectedOptions,
quantity,
setQuantity,
handleAddToCart,
handleBuyNow,
onOptionChange,
isOptionValueAvailable,
loading = false,
buyingNow = false,
addToCartLabel = 'Add to Cart',
}) => {
const formatPrice = (amount: string) => {
return `$${parseFloat(amount).toFixed(2)}`;
};
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
const hasDiscount =
compareAtPrice &&
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
const isAvailable = selectedVariant?.availableForSale ?? false;
const isSwatchOption = (option: ProductOption) =>
isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way.
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
const orderedOptions = [...(product.options ?? [])]
.filter((option) => !isDefaultTitleOption(option))
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
// `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
option.optionValues?.length
? option.optionValues
: option.values.map((value) => ({ id: value, name: value }));
return (
<div>
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
{product.title}
</h1>
<div className="flex items-baseline gap-x-3 mt-1">
<span className="font-mono tabular-nums tracking-tight text-base text-foreground">
{formatPrice(price.amount)}
</span>
{hasDiscount && compareAtPrice && (
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
)}
</div>
{/* Product Options — colour swatches lead, whatever order the API returns */}
{orderedOptions.map((option) => {
const isSwatch = isSwatchOption(option);
const selected = selectedOptions[option.name];
return (
<div key={option.id} className="mt-8">
<div className="text-sm text-muted-foreground mb-2">
{option.name}
{isSwatch && selected && (
<span className="text-foreground font-medium">: {selected}</span>
)}
</div>
<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);
return (
<Button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
variant="ghost"
size="icon-sm"
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,
}}
>
{!background && !image && (
<span className="text-[10px]">{value.name.at(0)}</span>
)}
</Button>
);
}
return (
<Button
key={value.id}
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>
);
})}
</div>
</div>
);
})}
{/* Quantity + Add to Cart */}
<div className="mt-8 flex items-stretch gap-3">
<div className="flex items-center rounded-md border border-border h-11">
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
variant="ghost"
size="icon"
aria-label="Decrease quantity"
className="h-full rounded-none rounded-l-md"
>
<RiSubtractLine size={16} />
</Button>
<span className="w-8 text-center text-sm tabular-nums">
{quantity}
</span>
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon"
aria-label="Increase quantity"
className="h-full rounded-none rounded-r-md"
>
<RiAddLine size={16} />
</Button>
</div>
<Button
onClick={handleAddToCart}
disabled={!isAvailable || loading}
className="flex-1 h-11"
>
{loading && <Loader size={16} />}
{isAvailable ? addToCartLabel : 'Out of Stock'}
</Button>
</div>
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
<ShopPayButton
className="mt-3"
variants={
selectedVariant ? [{ id: selectedVariant.id, quantity }] : []
}
disabled={!isAvailable || buyingNow}
loading={buyingNow}
onFallbackClick={handleBuyNow}
/>
{/* Description */}
{(product.descriptionHtml || product.description) && (
<div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? (
<div
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
/>
) : (
<p>{product.description}</p>
)}
</div>
)}
</div>
);
};
export default ProductDetailInfo;