Add React editor project

This commit is contained in:
Rami Bitar
2026-08-09 16:00:10 -04:00
parent 909d99251a
commit 63ecc5e284
212 changed files with 20655 additions and 11571 deletions
+133 -108
View File
@@ -1,23 +1,19 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useRouteSegment } from '@/hooks/use-route-segment';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo from './product-detail-info';
import { Button } from '@/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
interface ProductVariant {
id: string;
@@ -33,28 +29,36 @@ interface ProductVariant {
}>;
image?: {
url: string;
altText?: string;
};
altText?: string | null;
} | null;
}
export type { Product };
interface ProductDetailProps {
handle?: string;
addToCartLabel?: string;
}
const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) => {
const routeHandle = useRouteSegment();
const handle = handleProp || routeHandle || '';
const { addItem, openCart } = useShopifyCart();
const ProductDetail: React.FC<ProductDetailProps> = ({
handle: handleProp,
addToCartLabel = 'Add to Cart',
}) => {
const params = useParams();
const handle = handleProp || (params?.handle as string);
const { addItem, openCart, checkoutUrl } = useShopifyCart();
const { product, loading, error } = useProduct(handle);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
null
);
const [selectedOptions, setSelectedOptions] = useState<
Record<string, string>
>({});
const [quantity, setQuantity] = useState(1);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [addingToCart, setAddingToCart] = useState(false);
const [buyingNow, setBuyingNow] = useState(false);
// Initialize variant when product loads
useEffect(() => {
@@ -64,38 +68,47 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
setSelectedVariant(firstVariant);
const initialOptions: Record<string, string> = {};
firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => {
initialOptions[option.name] = option.value;
});
firstVariant.selectedOptions.forEach(
(option: { name: string; value: string }) => {
initialOptions[option.name] = option.value;
}
);
setSelectedOptions(initialOptions);
}
}
}, [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);
// Find matching variant
const matchingVariant = product?.variants.edges.find(({ node }) => {
return node.selectedOptions.every(option =>
newOptions[option.name] === option.value
return node.selectedOptions.every(
(option) => newOptions[option.name] === option.value
);
});
if (matchingVariant) {
setSelectedVariant(matchingVariant.node);
// Update image if variant has an associated image
if (matchingVariant.node.image && product) {
const variantImageUrl = matchingVariant.node.image.url;
const imageIndex = product.images.edges.findIndex(
edge => edge.node.url === variantImageUrl
);
if (imageIndex !== -1) {
setSelectedImageIndex(imageIndex);
}
}
}
};
@@ -113,89 +126,101 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
}
};
if (loading || !handle || !product) {
if (error && handle && !loading) {
return (
<div className="container mx-auto px-4 py-12">
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
<p className="text-sm font-medium">Product not found</p>
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
{error}
</p>
<Button
size="sm"
variant="outline"
onClick={() => window.history.back()}
>
Go back
</Button>
</div>
</div>
);
// Adds the item, then sends the shopper straight to the Shopify checkout
// (where Shop Pay is offered) rather than opening the cart drawer.
const handleBuyNow = async () => {
if (!selectedVariant || !product) return;
try {
setBuyingNow(true);
const updatedCart = await addItem(selectedVariant.id, quantity);
const url = updatedCart?.checkoutUrl ?? checkoutUrl;
if (url) {
redirectToCheckout(url);
} else {
openCart();
}
} catch (err) {
console.error('Failed to start checkout:', err);
} finally {
setBuyingNow(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div>
<Skeleton className="aspect-square w-full mb-4" />
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full" />
))}
</div>
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
{/* Image Gallery Skeleton */}
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="aspect-square bg-zinc-100 animate-pulse"
></div>
))}
</div>
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
{/* Product Info Skeleton */}
<div className="lg:col-span-2 animate-pulse">
<div className="h-8 bg-zinc-100 w-2/3"></div>
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
<div className="h-8 bg-zinc-100 w-32 mt-8"></div>
<div className="h-10 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-3"></div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/shop">Shop</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{product.title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<ProductDetailGallery
images={product.images.edges.map(edge => edge.node)}
selectedImageIndex={selectedImageIndex}
onImageSelect={setSelectedImageIndex}
/>
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
onOptionChange={handleOptionChange}
loading={addingToCart}
/>
<Empty className="min-h-[400px]">
<EmptyHeader>
<EmptyTitle>Product Not Found</EmptyTitle>
<EmptyDescription>
{error || 'The requested product could not be found.'}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => window.history.back()} variant="outline">
Go Back
</Button>
</EmptyContent>
</Empty>
</div>
);
}
return (
<div className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-start">
<div className="lg:col-span-3">
<ProductDetailGallery
images={product.images.edges.map((edge) => edge.node)}
/>
</div>
<div className="lg:col-span-2 lg:sticky lg:top-20">
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange}
isOptionValueAvailable={isOptionValueAvailable}
loading={addingToCart}
buyingNow={buyingNow}
addToCartLabel={addToCartLabel}
/>
</div>
</div>
</div>
</div>
@@ -1,66 +1,212 @@
import React from 'react';
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface ProductImage {
url: string;
altText?: string;
altText?: string | null;
width?: number | null;
height?: number | null;
}
interface ProductDetailGalleryProps {
images: ProductImage[];
selectedImageIndex?: number;
onImageSelect?: (index: number) => void;
}
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
images,
selectedImageIndex = 0,
onImageSelect
}) => {
const selectedImage = selectedImageIndex;
const setSelectedImage = onImageSelect || (() => {});
const [zoomedIndex, setZoomedIndex] = useState<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollerRef = useRef<HTMLDivElement>(null);
const isZoomed = zoomedIndex !== null;
const close = useCallback(() => setZoomedIndex(null), []);
// The slide whose left edge sits closest to the scroller's left edge is the
// one in view. Measuring rects keeps this correct whatever the gap or width.
const handleScroll = useCallback(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const scrollerLeft = scroller.getBoundingClientRect().left;
let nearest = 0;
let smallestOffset = Infinity;
Array.from(scroller.children).forEach((child, index) => {
const offset = Math.abs(child.getBoundingClientRect().left - scrollerLeft);
if (offset < smallestOffset) {
smallestOffset = offset;
nearest = index;
}
});
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];
if (!scroller || !slide) return;
const offset =
slide.getBoundingClientRect().left - scroller.getBoundingClientRect().left;
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
};
// Close on Escape, and keep the page behind the overlay from scrolling.
useEffect(() => {
if (!isZoomed) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
document.addEventListener('keydown', onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [isZoomed, close]);
if (images.length === 0) {
return (
<div className="aspect-square bg-zinc-50 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-[120px]"></i>
</div>
);
}
const isSingle = images.length === 1;
const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null;
return (
<div>
{/* Main Image */}
<div className="aspect-square bg-muted rounded-lg overflow-hidden mb-4">
{images.length > 0 ? (
<img
src={images[selectedImage].url}
alt={images[selectedImage].altText || 'Product image'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<i className="ri-image-line text-6xl"></i>
</div>
)}
<>
{/* Swipeable carousel on mobile, grid from sm up */}
<div
ref={scrollerRef}
onScroll={handleScroll}
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
key={index}
onClick={() => setZoomedIndex(index)}
aria-label={`Zoom ${image.altText || 'product image'}`}
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden pointer-events-none sm:pointer-events-auto sm:shrink sm:cursor-zoom-in ${
isSingle ? 'sm:col-span-2' : ''
}`}
>
<Image
src={image.url}
alt={image.altText || 'Product image'}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
priority={index === 0}
draggable={false}
className="object-cover select-none"
/>
</button>
))}
</div>
{/* Image Thumbnails */}
{images.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{images.map((image, index) => (
{/* Carousel pagination — the grid needs no dots, so mobile only */}
{!isSingle && (
<div className="flex justify-center gap-2 pt-4 sm:hidden">
{images.map((_, index) => (
<button
key={index}
onClick={() => setSelectedImage(index)}
className={`aspect-square rounded-lg overflow-hidden border-2 transition-colors ${
selectedImage === index
? 'border-foreground'
: 'border-border hover:border-muted-foreground'
onClick={() => scrollToIndex(index)}
aria-label={`Go to image ${index + 1}`}
aria-current={index === activeIndex}
className={`h-1.5 rounded-full transition-all ${
index === activeIndex
? 'w-5 bg-foreground'
: 'w-1.5 bg-border hover:bg-foreground/40'
}`}
>
<img
src={image.url}
alt={image.altText || 'Product thumbnail'}
className="w-full h-full object-cover"
/>
</button>
/>
))}
</div>
)}
</div>
{zoomedImage && (
<div
role="dialog"
aria-modal="true"
aria-label={zoomedImage.altText || 'Product image'}
onClick={close}
className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10"
>
<Image
src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'}
// Shopify gives us the intrinsic size; the square fallback only
// reserves space until CSS scales it down to fit the overlay.
width={zoomedImage.width || 1600}
height={zoomedImage.height || 1600}
sizes="100vw"
onClick={(event) => event.stopPropagation()}
// w/h-auto keeps the box at the image's own ratio; without it the
// width+height attributes make both axes definite and the element
// stretches to the overlay, swallowing backdrop clicks that close it.
className="max-h-full max-w-full w-auto h-auto object-contain"
/>
<Button
onClick={close}
variant="ghost"
size="icon-lg"
aria-label="Close"
className="absolute top-4 right-4 rounded-full bg-background shadow-sm hover:bg-secondary"
>
<RiCloseLine size={20} />
</Button>
</div>
)}
</>
);
};
export default ProductDetailGallery;
export default ProductDetailGallery;
@@ -1,8 +1,47 @@
import React from 'react';
import { Product, ProductVariant } from './index.tsx';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
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;
@@ -11,10 +50,31 @@ interface ProductDetailInfoProps {
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,
@@ -22,137 +82,201 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
quantity,
setQuantity,
handleAddToCart,
handleBuyNow,
onOptionChange,
isOptionValueAvailable,
loading = false,
buyingNow = false,
addToCartLabel = 'Add to Cart',
}) => {
const formatPrice = (price: { amount: string; currencyCode: string }) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(parseFloat(price.amount));
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 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-4xl font-bold text-foreground mb-4 font-heading">
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
{product.title}
</h1>
{/* Price */}
<div className="flex items-center space-x-4 mb-6">
<span className="text-2xl font-bold text-foreground">
{formatPrice(price)}
<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="text-xl text-muted-foreground line-through">
{formatPrice(compareAtPrice)}
</span>
<Badge variant="destructive">
{Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF
</Badge>
</>
<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={{
// With no colour and no image the circle would be fully
// transparent, leaving just a hairline ring that
// antialiases unevenly and reads as a speckled border.
// A neutral fill makes the initial-letter fallback look
// deliberate. Set inline so it beats the ghost variant's
// hover background.
backgroundColor:
background ??
(image ? undefined : 'var(--color-muted)'),
backgroundImage: image ? `url(${image})` : undefined,
}}
>
{!background && !image && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
{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.description && (
<div className="text-muted-foreground mb-8 text-lg leading-relaxed">
{(product.descriptionHtml || product.description) && (
<div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? (
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
<div
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
/>
) : (
<p>{product.description}</p>
)}
</div>
)}
{/* Product Options */}
{product.options.map(option => (
<div key={option.id} className="mb-6">
<label className="block text-sm font-semibold text-foreground mb-2">
{option.name}
</label>
<div className="flex flex-wrap gap-2">
{option.values.map(value => (
<Button
key={value}
onClick={() => onOptionChange(option.name, value)}
variant={selectedOptions[option.name] === value ? 'default' : 'outline'}
>
{value}
</Button>
))}
</div>
</div>
))}
{/* Quantity Selector */}
<div className="mb-8">
<label className="block text-sm font-semibold text-foreground mb-2">
Quantity
</label>
<div className="flex items-center border border-border rounded-lg w-fit">
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
variant="ghost"
size="icon-sm"
disabled={quantity <= 1}
>
<i className="ri-subtract-line"></i>
</Button>
<span className="w-10 text-center font-semibold">{quantity}</span>
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon-sm"
>
<i className="ri-add-line"></i>
</Button>
</div>
</div>
{/* Add to Cart Button */}
<Button
onClick={handleAddToCart}
disabled={!selectedVariant?.availableForSale || loading}
size="lg"
className="w-full text-lg"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<Spinner size="sm" />
<span>Adding...</span>
</span>
) : selectedVariant?.availableForSale ? (
'Add to Cart'
) : (
'Out of Stock'
)}
</Button>
{/* Additional Info */}
<div className="mt-8 pt-8 border-t border-border">
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<i className="ri-truck-line"></i>
<span>Free shipping on orders over $100</span>
</div>
<div className="flex items-center space-x-2">
<i className="ri-arrow-go-back-line"></i>
<span>30-day return policy</span>
</div>
<div className="flex items-center space-x-2">
<i className="ri-secure-payment-line"></i>
<span>Secure payment</span>
</div>
</div>
</div>
</div>
);
};
export default ProductDetailInfo;
export default ProductDetailInfo;