Files
shopify-template/components/shopify/product-detail/index.tsx
T
Rami BitarandClaude Opus 5 f8c3ef9e0e Move Storefront queries onto @shopify/hydrogen preview client
Replaces the hand-rolled shopifyFetch wrapper and raw query strings with
the hydrogen preview package (0.0.0-preview-116d5d7-20260730141607), the
same build pinned in hydrogen-preview/.

- services/shopify/client: createStorefrontClient({type: "public"}) with a
  static request context. Two module-scoped clients — uncached for carts,
  products and customers; revalidate-3600 for shop policies — using the
  client's custom fetch option to carry Next's caching hints.
- graphql/*: every document wrapped in gql(), fragments composed via the
  second argument instead of string interpolation, and $country/$language
  declared with @inContext so hydrogen injects them.
- shopifyFetch keeps its {query, variables} call shape so call sites are
  unchanged, but is now generic over the document, so data is inferred. It
  re-raises GraphQL errors, which hydrogen returns rather than throws.

Env var names, the 2025-07 API version pin, and the client-side fetching
architecture are unchanged.

Turning on type coverage surfaced real defects, not just annotations:

- hydrogen gql check caught $discountCodes: [String!] used where the field
  requires [String!]!.
- Cart and customer mutation payloads are nullable and were dereferenced
  unconditionally, so a failed mutation threw a TypeError. Adds a shared
  unwrapCartPayload helper (collapsing five copies of the same userErrors
  check) and explicit null handling in the customer service, so a null
  payload reads as an error rather than success with no errors.
- Search results are a Product | Page | Article union; adds __typename to
  the queries and narrows on it.
- Widens nullable fields (altText, image, customer.email, totalTaxAmount)
  in the domain interfaces and in the structural duplicates some
  components declare locally.

Adds a typecheck script (tsc --noEmit && hydrogen gql check); it passes
with 0 errors. Two deprecation warnings are left alone as acting on them
would change behaviour: ProductOption.values and CartCost.totalTaxAmount.

Verified against mock.shop: build prerenders the policy pages through the
cached client, and in the browser the product grid, search with facets,
collection filter round-trip, add-to-cart and quantity update all work.
Customer account flows are untested — they need real credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC
2026-08-08 10:24:38 -04:00

231 lines
6.9 KiB
TypeScript

'use client';
import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
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 {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
interface ProductVariant {
id: string;
title: string;
price: {
amount: string;
currencyCode: string;
};
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: {
url: string;
altText?: string | null;
} | null;
}
export type { Product };
interface ProductDetailProps {
handle?: string;
addToCartLabel?: string;
}
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 [quantity, setQuantity] = useState(1);
const [addingToCart, setAddingToCart] = useState(false);
const [buyingNow, setBuyingNow] = useState(false);
// Initialize variant when product loads
useEffect(() => {
if (product) {
const firstVariant = product.variants.edges[0]?.node;
if (firstVariant) {
setSelectedVariant(firstVariant);
const initialOptions: Record<string, string> = {};
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
);
});
if (matchingVariant) {
setSelectedVariant(matchingVariant.node);
}
};
const handleAddToCart = async () => {
if (!selectedVariant || !product) return;
try {
setAddingToCart(true);
await addItem(selectedVariant.id, quantity);
openCart();
} catch (err) {
console.error('Failed to add item to cart:', err);
} finally {
setAddingToCart(false);
}
};
// 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="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>
{/* 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>
);
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8">
<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>
);
};
export default ProductDetail;