diff --git a/apps/docs/content/docs/anatomy/pages/pdp.mdx b/apps/docs/content/docs/anatomy/pages/pdp.mdx index fc0a101c..2afdc8a5 100644 --- a/apps/docs/content/docs/anatomy/pages/pdp.mdx +++ b/apps/docs/content/docs/anatomy/pages/pdp.mdx @@ -12,7 +12,7 @@ The Product Detail Page (PDP) is a single product page — title, price, media, **Static-first rendering.** Product data is awaited at the top of the page and baked into the static shell, so the title, price, gallery, and description ship as one coherent copy. Freshness comes from cache invalidation — a Shopify webhook calling `revalidateTag`, plus the `cacheLife` window — not a per-request fetch, so the body never re-renders at request time. -**Variant selection through URL searchParams.** Each option change is a navigation (`/products/tee?color=Blue&size=XS`), not client state. That gives you shareable URLs, working browser back/forward, and a fully server-rendered page with zero layout shift. The selected variant resolves from a second, per-selection Shopify query kept off the critical path, so the picker highlights at URL speed rather than waiting on the network. +**Instant variant selection with shareable URLs.** The initial selection comes from URL search params (`/products/tee?color=Blue&size=XS`) and Shopify resolves the matching variant on the server. After hydration, Hydrogen derives existence, availability, and most selected variants locally from encoded option data plus a sparse set of adjacent variants, so price, options, and purchase controls update immediately instead of waiting for the route request. Each option remains a real link and replaces the URL, preserving refresh, sharing, and no-JavaScript behavior; selections outside the sparse local set resolve through the server route. ## Out of the box diff --git a/apps/template/components/product-detail/color-picker.tsx b/apps/template/components/product-detail/color-picker.tsx index 244b6fd8..8c6d22a5 100644 --- a/apps/template/components/product-detail/color-picker.tsx +++ b/apps/template/components/product-detail/color-picker.tsx @@ -1,4 +1,3 @@ -import { getTranslations } from "next-intl/server"; import Link from "next/link"; import type * as React from "react"; @@ -7,26 +6,33 @@ import { buildOptionUrl, type SelectedOptions } from "@/lib/product"; import type { ProductOption } from "@/lib/types"; import { cn } from "@/lib/utils"; -export type ProductTranslator = Awaited>>; +export interface ProductOptionLabels { + selectVariant: Record>; + unavailableVariant: Record>; +} interface ColorPickerProps extends React.ComponentProps<"div"> { - option: ProductOption; - selectedValue: string; available: Set | undefined; + existing?: Set; handle: string; - selectedOptions: SelectedOptions; - t: ProductTranslator; hideImages?: boolean; + onOptionSelect?: (name: string, value: string) => void; + labels: ProductOptionLabels; + option: ProductOption; + selectedOptions: SelectedOptions; + selectedValue: string; } export function ColorPicker({ - option, - selectedValue, available, + existing, handle, - selectedOptions, - t, hideImages, + onOptionSelect, + labels, + option, + selectedOptions, + selectedValue, className, ...props }: ColorPickerProps) { @@ -38,6 +44,7 @@ export function ColorPicker({
{option.values.map((value) => { const isSelected = selectedValue === value.name; + const exists = !existing || existing.has(value.name); const isAvailable = !available || available.has(value.name); const imageUrl = hideImages ? undefined @@ -53,12 +60,12 @@ export function ColorPicker({ /> ); - if (!isAvailable) { + if (!exists) { return ( {swatch} @@ -69,9 +76,11 @@ export function ColorPicker({ onOptionSelect(option.name, value.name) : undefined} > {swatch} diff --git a/apps/template/components/product-detail/option-picker.tsx b/apps/template/components/product-detail/option-picker.tsx index 5faba974..d7e4f92e 100644 --- a/apps/template/components/product-detail/option-picker.tsx +++ b/apps/template/components/product-detail/option-picker.tsx @@ -6,19 +6,23 @@ import type { ProductOption } from "@/lib/types"; import { cn } from "@/lib/utils"; interface OptionPickerProps extends React.ComponentProps<"div"> { - option: ProductOption; - selectedValue: string; available: Set | undefined; + existing?: Set; handle: string; + onOptionSelect?: (name: string, value: string) => void; + option: ProductOption; selectedOptions: SelectedOptions; + selectedValue: string; } export function OptionPicker({ - option, - selectedValue, available, + existing, handle, + onOptionSelect, + option, selectedOptions, + selectedValue, className, ...props }: OptionPickerProps) { @@ -29,17 +33,19 @@ export function OptionPicker({ {option.values.map((value) => { const isSelected = selectedValue === value.name; + const exists = !existing || existing.has(value.name); const isAvailable = !available || available.has(value.name); const href = buildOptionUrl(handle, selectedOptions, option.name, value.name); const classes = cn( "grid px-5 py-2 text-center text-sm rounded-lg transition-all border", - !isAvailable + !exists ? "font-normal border-dashed border-border text-muted-foreground/50 line-through cursor-not-allowed" : isSelected ? "font-medium border-foreground text-foreground starting:border-border starting:text-muted-foreground" : "font-normal border-border text-muted-foreground hover:border-foreground hover:text-foreground", + !isAvailable && exists && "opacity-50", ); // Invisible medium-weight twin reserves the bold width so pills don't shift on selection. @@ -52,7 +58,7 @@ export function OptionPicker({ ); - if (!isAvailable) { + if (!exists) { return ( {label} @@ -61,7 +67,14 @@ export function OptionPicker({ } return ( - + onOptionSelect(option.name, value.name) : undefined} + > {label} ); diff --git a/apps/template/components/product-detail/product-detail-section.tsx b/apps/template/components/product-detail/product-detail-section.tsx index 2f543b02..d25ea890 100644 --- a/apps/template/components/product-detail/product-detail-section.tsx +++ b/apps/template/components/product-detail/product-detail-section.tsx @@ -5,6 +5,7 @@ import { Suspense } from "react"; import { BundleComponents, BundleParents } from "@/components/product-detail/bundle-components"; import { BuyButtons, type BuyButtonVariant } from "@/components/product-detail/buy-buttons"; import { BuyWithShopLogo } from "@/components/product-detail/buy-with-shop-logo"; +import type { ProductOptionLabels } from "@/components/product-detail/color-picker"; import { ComplementaryProducts } from "@/components/product-detail/complementary-products"; import { GiftCardPurchaseForm } from "@/components/product-detail/gift-card-purchase-form"; import { ProductOpenGraph } from "@/components/product-detail/open-graph"; @@ -18,6 +19,7 @@ import { ProductMedia, } from "@/components/product-detail/product-media"; import { ProductPrice } from "@/components/product-detail/product-price"; +import { ProductPurchaseControls } from "@/components/product-detail/product-purchase-controls-client"; import { ProductSchema } from "@/components/product-detail/schema"; import { BreadcrumbSchema } from "@/components/schema/breadcrumb-schema"; import { Input } from "@/components/ui/input"; @@ -172,53 +174,53 @@ async function ProductInfoArea({ locale: Locale; }) { const { options, handle, title, featuredImage, descriptionHtml, availableForSale } = product; - const uniformPrice = product.hasUniformPricing; - const uniformStock = product.allVariantsInStock; const singleVariant = product.variantsCount === 1; const availableValues = getAvailableOptionValues(options, product.encodedVariantAvailability); const eagerSelection = singleVariant ? { selectedOptions: defaultSelectedOptions(product), selectedVariant: product.defaultVariant } : null; const t = await getTranslations("product"); - const buyFallbackT = uniformStock && !singleVariant ? t : null; - const allInStock = product.defaultVariant?.availableForSale ?? availableForSale; + const optionLabels = buildProductOptionLabels(options, t); return (
-
-

{title}

- {uniformPrice ? ( - - ) : ( - // h-7 matches the resolved price's text-xl line-height (1.75rem) — keep in sync to avoid CLS - }> - - - )} -
+ {eagerSelection || product.isGiftCard ? ( +
+

{title}

+ {eagerSelection ? ( + + ) : ( + }> + + + )} +
+ ) : null} {eagerSelection ? ( - ) : ( + ) : product.isGiftCard ? ( } @@ -227,11 +229,11 @@ async function ProductInfoArea({ availableValues={availableValues} options={options} handle={handle} + labels={optionLabels} selectedOptionsPromise={selectedOptionsPromise} - t={t} /> - )} + ) : null} {product.isGiftCard ? ( }> @@ -252,14 +254,15 @@ async function ProductInfoArea({ quantityPicker={shopConfig.pdp.quantityPicker.isEnabled} /> ) : ( - }> - + } + > + @@ -297,11 +300,11 @@ function BundleRelationships({ } async function ResolvedProductPrice({ - variantPromise, locale, + variantPromise, }: { - variantPromise: Promise; locale: Locale; + variantPromise: Promise; }) { const selectedVariant = await variantPromise; if (!selectedVariant) return null; @@ -317,25 +320,25 @@ async function ResolvedProductPrice({ async function ResolvedProductInfoOptions({ availableValues, - options, handle, + labels, + options, selectedOptionsPromise, - t, }: { availableValues: Map>; - options: ProductDetails["options"]; handle: string; + labels: ProductOptionLabels; + options: ProductDetails["options"]; selectedOptionsPromise: Promise; - t: Awaited>>; }) { const selectedOptions = await selectedOptionsPromise; return ( ); } @@ -354,37 +357,93 @@ function toBuyButtonVariant(variant: ProductVariant | undefined): BuyButtonVaria }; } -async function ResolvedBuyButtons({ - availableForSale, - buyWithShop, - featuredImage, - handle, - quantityPicker, - title, +async function ResolvedProductPurchaseControls({ + labels, + locale, + product, variantPromise, }: { - availableForSale: boolean; - buyWithShop: boolean; - featuredImage: ProductDetails["featuredImage"]; - handle: string; - quantityPicker: boolean; - title: string; + labels: ProductOptionLabels; + locale: Locale; + product: ProductDetails; variantPromise: Promise; }) { - const selectedVariant = await variantPromise; return ( - ); } +function buildProductOptionLabels( + options: ProductDetails["options"], + t: Awaited>>, +): ProductOptionLabels { + return { + selectVariant: Object.fromEntries( + options.map((option) => [ + option.name, + Object.fromEntries( + option.values.map((value) => [ + value.name, + t("selectVariantLabel", { name: option.name, value: value.name }), + ]), + ), + ]), + ), + unavailableVariant: Object.fromEntries( + options.map((option) => [ + option.name, + Object.fromEntries( + option.values.map((value) => [ + value.name, + t("unavailableVariantLabel", { name: option.name, value: value.name }), + ]), + ), + ]), + ), + }; +} + +function ProductPurchaseControlsFallback({ + labels, + product, + t, +}: { + labels: ProductOptionLabels; + product: ProductDetails; + t: Awaited>>; +}) { + return ( + <> +
+

{product.title}

+
+
+ + + + ); +} + async function ResolvedGiftCardPurchaseForm({ eagerVariantId, product, diff --git a/apps/template/components/product-detail/product-info.tsx b/apps/template/components/product-detail/product-info.tsx index 83b08a8e..fff4eb89 100644 --- a/apps/template/components/product-detail/product-info.tsx +++ b/apps/template/components/product-detail/product-info.tsx @@ -5,7 +5,7 @@ import type { ProductOption, ProductVariant } from "@/lib/types"; import { cn } from "@/lib/utils"; import { AboutItem } from "./about-item"; -import { ColorPicker, type ProductTranslator } from "./color-picker"; +import { ColorPicker, type ProductOptionLabels } from "./color-picker"; import { OptionPicker } from "./option-picker"; import { ProductPrice } from "./product-price"; @@ -40,20 +40,24 @@ function ProductInfoHeader({ interface ProductInfoOptionsProps extends React.ComponentProps<"div"> { availableValues: Map>; - options: ProductOption[]; - selectedOptions: SelectedOptions; + existingValues?: Map>; handle: string; - t: ProductTranslator; hideImages?: boolean; + labels: ProductOptionLabels; + onOptionSelect?: (name: string, value: string) => void; + options: ProductOption[]; + selectedOptions: SelectedOptions; } function ProductInfoOptions({ availableValues, - options, - selectedOptions, + existingValues, handle, - t, hideImages, + labels, + onOptionSelect, + options, + selectedOptions, className, ...props }: ProductInfoOptionsProps) { @@ -88,9 +92,11 @@ function ProductInfoOptions({ option={colorOption} selectedValue={selectedOptions[colorOption.name] ?? ""} available={availableValues.get(colorOption.name)} + existing={existingValues?.get(colorOption.name)} handle={handle} + labels={labels} + onOptionSelect={onOptionSelect} selectedOptions={selectedOptions} - t={t} hideImages={hideImages} /> ))} @@ -101,7 +107,9 @@ function ProductInfoOptions({ option={option} selectedValue={selectedOptions[option.name] ?? ""} available={availableValues.get(option.name)} + existing={existingValues?.get(option.name)} handle={handle} + onOptionSelect={onOptionSelect} selectedOptions={selectedOptions} /> ))} diff --git a/apps/template/components/product-detail/product-purchase-controls-client.tsx b/apps/template/components/product-detail/product-purchase-controls-client.tsx new file mode 100644 index 00000000..b56d582b --- /dev/null +++ b/apps/template/components/product-detail/product-purchase-controls-client.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { createProductComponents } from "@shopify/hydrogen/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; + +import { ProductInfoOptions } from "@/components/product-detail/product-info"; +import { ProductPrice } from "@/components/product-detail/product-price"; +import type { Locale } from "@/lib/i18n"; +import type { SelectedOptions } from "@/lib/product"; +import type { ProductDetails, ProductVariant } from "@/lib/types"; + +import { BuyButtons } from "./buy-buttons"; +import type { ProductOptionLabels } from "./color-picker"; + +interface ProductFormProduct { + adjacentVariants: ProductVariant[]; + encodedVariantAvailability?: string; + encodedVariantExistence?: string; + handle: string; + id: string; + options: Array<{ + name: string; + optionValues: Array<{ + firstSelectableVariant?: ProductVariant; + name: string; + swatch?: ProductDetails["options"][number]["values"][number]["swatch"]; + }>; + }>; + selectedOrFirstAvailableVariant: ProductVariant | null; + title: string; +} + +const { ProductProvider, useProduct } = createProductComponents(); + +interface ProductPurchaseControlsProps { + buyWithShop: boolean; + labels: ProductOptionLabels; + locale: Locale; + product: ProductDetails; + quantityPicker: boolean; + selectedVariant: ProductVariant | undefined; +} + +export function ProductPurchaseControls({ + buyWithShop, + labels, + locale, + product, + quantityPicker, + selectedVariant, +}: ProductPurchaseControlsProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const productFormProduct = useMemo( + (): ProductFormProduct => ({ + adjacentVariants: product.adjacentVariants, + encodedVariantAvailability: product.encodedVariantAvailability, + encodedVariantExistence: product.encodedVariantExistence, + handle: product.handle, + id: product.id, + options: product.options.map((option) => ({ + name: option.name, + optionValues: option.values.map((value) => ({ + firstSelectableVariant: value.firstSelectableVariant, + name: value.name, + swatch: value.swatch, + })), + })), + selectedOrFirstAvailableVariant: selectedVariant ?? null, + title: product.title, + }), + [product, selectedVariant], + ); + + return ( + { + const next = new URLSearchParams(searchParams); + for (const option of product.options) next.delete(option.name.toLowerCase()); + for (const option of result.selectedOptions) { + next.set(option.name.toLowerCase(), option.value); + } + const query = next.toString(); + router.replace(`/products/${product.handle}${query ? `?${query}` : ""}`, { + scroll: false, + }); + }} + > + + + ); +} + +function ProductPurchaseControlsContent({ + buyWithShop, + labels, + locale, + product, + quantityPicker, +}: Omit) { + const { options, selectedVariant, selectOption } = useProduct(); + const selectedOptions: SelectedOptions = Object.fromEntries( + options.flatMap((option) => + option.values.filter((value) => value.selected).map((value) => [option.name, value.name]), + ), + ); + const availableValues = new Map( + options.map((option) => [ + option.name, + new Set(option.values.filter((value) => value.available).map((value) => value.name)), + ]), + ); + const existingValues = new Map( + options.map((option) => [ + option.name, + new Set(option.values.filter((value) => value.exists).map((value) => value.name)), + ]), + ); + + return ( + <> +
+

{product.title}

+ {selectedVariant ? ( + + ) : ( +
+ )} +
+ + + + ); +} diff --git a/apps/template/lib/shopify/fragments.ts b/apps/template/lib/shopify/fragments.ts index 3d943e22..3f3b8384 100644 --- a/apps/template/lib/shopify/fragments.ts +++ b/apps/template/lib/shopify/fragments.ts @@ -354,6 +354,9 @@ export const PRODUCT_FRAGMENT = `#graphql selectedOrFirstAvailableVariant { ...ProductVariantFields } + adjacentVariants { + ...ProductVariantFields + } options { id name @@ -370,9 +373,7 @@ export const PRODUCT_FRAGMENT = `#graphql } } firstSelectableVariant { - image { - ...ImageFields - } + ...ProductVariantFields } } } diff --git a/apps/template/lib/shopify/operations/products.ts b/apps/template/lib/shopify/operations/products.ts index 6e61cfc1..c9376c50 100644 --- a/apps/template/lib/shopify/operations/products.ts +++ b/apps/template/lib/shopify/operations/products.ts @@ -87,6 +87,16 @@ const GET_PRODUCT_BY_HANDLE_WITH_BUNDLES_QUERY = `#graphql query getProductByHandleWithBundles($handle: String!, $country: CountryCode, $language: LanguageCode) @inContext(country: $country, language: $language) { productByHandle(handle: $handle) { ...ProductFields + adjacentVariants { + ...BundleRelationshipFields + } + options { + optionValues { + firstSelectableVariant { + ...BundleRelationshipFields + } + } + } selectedOrFirstAvailableVariant { ...BundleRelationshipFields } diff --git a/apps/template/lib/shopify/transforms/product.ts b/apps/template/lib/shopify/transforms/product.ts index 9532b9ce..15d29d93 100644 --- a/apps/template/lib/shopify/transforms/product.ts +++ b/apps/template/lib/shopify/transforms/product.ts @@ -59,10 +59,10 @@ interface ShopifyOptionValueSwatch { } interface ShopifyOptionValue { + firstSelectableVariant?: ShopifyVariant | null; id: string; name: string; swatch: ShopifyOptionValueSwatch | null; - firstSelectableVariant?: { image: ShopifyImage | null } | null; } interface ShopifyOption { @@ -125,6 +125,7 @@ export interface ShopifyProduct { minVariantPrice: ShopifyMoney; maxVariantPrice: ShopifyMoney; } | null; + adjacentVariants?: ShopifyVariant[]; encodedVariantAvailability?: string | null; encodedVariantExistence?: string | null; variantsCount: { count: number }; @@ -279,26 +280,23 @@ function transformSwatch(swatch: ShopifyOptionValueSwatch | null): OptionValueSw } function transformOption(option: ShopifyOption): ProductOption { - const swatchLookup = new Map(); - const imageLookup = new Map(); - if (option.optionValues) { - for (const ov of option.optionValues) { - swatchLookup.set(ov.name, transformSwatch(ov.swatch)); - imageLookup.set(ov.name, ov.firstSelectableVariant?.image?.url); - } - } + const optionValueLookup = new Map(option.optionValues?.map((value) => [value.name, value])); return { id: option.id, name: option.name, - values: option.values.map( - (value): OptionValue => ({ + values: option.values.map((value): OptionValue => { + const optionValue = optionValueLookup.get(value); + return { + firstSelectableVariant: optionValue?.firstSelectableVariant + ? transformVariant(optionValue.firstSelectableVariant) + : undefined, id: value, - image: imageLookup.get(value), + image: optionValue?.firstSelectableVariant?.image?.url, name: value, - swatch: swatchLookup.get(value), - }), - ), + swatch: transformSwatch(optionValue?.swatch ?? null), + }; + }), }; } @@ -351,6 +349,7 @@ export function transformShopifyProductDetails(product: ShopifyProduct): Product vendor: product.vendor || undefined, availableForSale: product.availableForSale, isGiftCard: product.isGiftCard, + adjacentVariants: product.adjacentVariants?.map(transformVariant) ?? [], allVariantsInStock: !product.encodedVariantExistence || product.encodedVariantExistence === product.encodedVariantAvailability, diff --git a/apps/template/lib/types.ts b/apps/template/lib/types.ts index c8b9b610..2ebcf982 100644 --- a/apps/template/lib/types.ts +++ b/apps/template/lib/types.ts @@ -53,6 +53,7 @@ export interface ProductCard { } export interface ProductDetails extends ProductCard { + adjacentVariants: ProductVariant[]; allVariantsInStock: boolean; category?: Category | null; categoryId?: string; @@ -126,6 +127,7 @@ export interface OptionValueSwatch { } export interface OptionValue { + firstSelectableVariant?: ProductVariant; id: string; image?: string; name: string;