Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/content/docs/anatomy/pages/pdp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ The Product Detail Page (PDP) gives shoppers the information and controls needed

## Help shoppers choose the right variant

Product pages show the title, price, media, description, and available options. Option choices update the page URL, so shoppers can share a selected color, size, or other variant and use browser back and forward controls.
Product pages show the title, price, media, description, and available options. Option choices update immediately, then the price, availability, and purchase controls confirm when the selected variant resolves. Purchase controls stay disabled during that brief transition so shoppers cannot add the previous variant by mistake.

When colors have distinct media, the gallery leads with the selected color's image. Desktop shoppers get a grid and lightbox; mobile shoppers get a swipeable gallery.
Each choice also updates the page URL, so shoppers can share a selected color, size, or other variant and use browser back and forward controls. When colors have distinct media, the gallery switches immediately to the selected color's image. Desktop shoppers get a grid and lightbox; mobile shoppers get a swipeable gallery.

Product updates are cached and refreshed by Shopify webhooks. See [Webhooks](/docs/anatomy/webhooks).

Expand Down
6 changes: 4 additions & 2 deletions apps/template/components/product-detail/buy-buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState, useTransition } from "react";

import { useCart } from "@/components/cart/context";
import { useVariantSelection } from "@/components/product-detail/variant-selection-client";
import { Button } from "@/components/ui/button";
import { buyNowAction } from "@/lib/cart/action";
import { variantToOptimisticInfo } from "@/lib/product";
Expand Down Expand Up @@ -42,6 +43,7 @@ export function BuyButtons({
title: string;
}) {
const selectedVariantId = selectedVariant?.id;
const { isPending } = useVariantSelection();

const t = useTranslations("product");
const tCart = useTranslations("cart");
Expand Down Expand Up @@ -143,7 +145,7 @@ export function BuyButtons({
) : null}
<Button
type="button"
disabled={isOutOfStock || requiresBundleConfiguration}
disabled={isPending || isOutOfStock || requiresBundleConfiguration}
onClick={handleAddToCart}
className="h-12 min-w-0 flex-1 justify-center"
>
Expand All @@ -157,7 +159,7 @@ export function BuyButtons({
"flex h-12 w-full cursor-pointer items-center justify-center rounded-lg bg-shop px-4 text-white transition-colors hover:bg-shop/85 disabled:cursor-not-allowed disabled:opacity-50",
!availableForSale && "invisible",
)}
disabled={isOutOfStock || isBuyingNow || requiresBundleConfiguration}
disabled={isPending || isOutOfStock || isBuyingNow || requiresBundleConfiguration}
onClick={handleBuyNow}
>
{isBuyingNow ? (
Expand Down
17 changes: 14 additions & 3 deletions apps/template/components/product-detail/color-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { getTranslations } from "next-intl/server";
"use client";

import type { getTranslations } from "next-intl/server";
import Link from "next/link";
import type * as React from "react";

import { useVariantSelection } from "@/components/product-detail/variant-selection-client";
import { Swatch } from "@/components/ui/swatch";
import { buildOptionUrl, type SelectedOptions } from "@/lib/product";
import type { ProductOption } from "@/lib/types";
Expand Down Expand Up @@ -30,14 +33,18 @@ export function ColorPicker({
className,
...props
}: ColorPickerProps) {
const optimisticSelection = useVariantSelection();
const currentOptions = optimisticSelection.optimisticOptions ?? selectedOptions;
const currentValue = currentOptions[option.name] ?? selectedValue;

return (
<div className={cn("grid gap-2.5", className)} {...props}>
<p className="text-sm font-medium text-foreground/70">
{option.name}: <span className="text-foreground">{selectedValue}</span>
{option.name}: <span className="text-foreground">{currentValue}</span>
</p>
<div className="flex flex-wrap gap-2.5">
{option.values.map((value) => {
const isSelected = selectedValue === value.name;
const isSelected = currentValue === value.name;
const isAvailable = !available || available.has(value.name);
const imageUrl = hideImages
? undefined
Expand Down Expand Up @@ -72,6 +79,10 @@ export function ColorPicker({
scroll={false}
className="block cursor-pointer"
aria-label={t("selectVariantLabel", { name: option.name, value: value.name })}
onNavigate={(event) => {
event.preventDefault();
optimisticSelection.selectOption(selectedOptions, option.name, value.name);
}}
>
{swatch}
</Link>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
import { useState } from "react";

import { useCart } from "@/components/cart/context";
import { useVariantSelection } from "@/components/product-detail/variant-selection-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand Down Expand Up @@ -45,6 +46,7 @@ function giftCardAttributes(recipient: {
export function GiftCardPurchaseForm({ merchandiseId, productInfo }: GiftCardPurchaseFormProps) {
const t = useTranslations("product.giftCard");
const { setOverlayOpen, setWarnings } = useCart();
const { isPending } = useVariantSelection();
const [error, setError] = useState<string | null>(null);
const [sendOnEnabled, setSendOnEnabled] = useState(false);

Expand Down Expand Up @@ -155,6 +157,7 @@ export function GiftCardPurchaseForm({ merchandiseId, productInfo }: GiftCardPur

<Button
type="submit"
disabled={isPending}
className={cn(
"h-12 w-full justify-center",
"group-invalid:cursor-not-allowed group-invalid:opacity-50",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import { ColorImageCarouselItems, ColorImageGrid } from "@/components/product-detail/product-media";
import { useVariantSelection } from "@/components/product-detail/variant-selection-client";
import { getSelectedColorImage, type SelectedOptions } from "@/lib/product";
import type { ProductDetails } from "@/lib/types";

interface OptimisticColorImageProps {
layout: "carousel" | "grid";
product: ProductDetails;
selectedOptions: SelectedOptions;
}

export function OptimisticColorImage({
layout,
product,
selectedOptions,
}: OptimisticColorImageProps) {
const { optimisticOptions } = useVariantSelection();
const image = getSelectedColorImage(product, optimisticOptions ?? selectedOptions);
if (!image) return null;
return layout === "grid" ? (
<ColorImageGrid images={[image]} title={product.title} />
) : (
<ColorImageCarouselItems images={[image]} title={product.title} />
);
}
19 changes: 17 additions & 2 deletions apps/template/components/product-detail/option-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"use client";

import Link from "next/link";
import type * as React from "react";

import { useVariantSelection } from "@/components/product-detail/variant-selection-client";
import { buildOptionUrl, type SelectedOptions } from "@/lib/product";
import type { ProductOption } from "@/lib/types";
import { cn } from "@/lib/utils";
Expand All @@ -22,12 +25,15 @@ export function OptionPicker({
className,
...props
}: OptionPickerProps) {
const optimisticSelection = useVariantSelection();
const currentOptions = optimisticSelection.optimisticOptions ?? selectedOptions;

return (
<div className={cn("grid gap-2.5", className)} {...props}>
<p className="text-sm font-medium text-foreground/70">{option.name}</p>
<div className="flex flex-wrap gap-2">
{option.values.map((value) => {
const isSelected = selectedValue === value.name;
const isSelected = (currentOptions[option.name] ?? selectedValue) === value.name;

const isAvailable = !available || available.has(value.name);

Expand Down Expand Up @@ -61,7 +67,16 @@ export function OptionPicker({
}

return (
<Link key={value.id} href={href} scroll={false} className={classes}>
<Link
key={value.id}
href={href}
scroll={false}
className={classes}
onNavigate={(event) => {
event.preventDefault();
optimisticSelection.selectOption(selectedOptions, option.name, value.name);
}}
>
{label}
</Link>
);
Expand Down
44 changes: 23 additions & 21 deletions apps/template/components/product-detail/product-detail-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,15 @@ import { BuyWithShopLogo } from "@/components/product-detail/buy-with-shop-logo"
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";
import { OptimisticColorImage } from "@/components/product-detail/optimistic-color-image-client";
import {
ProductInfoDescription,
ProductInfoOptions,
} from "@/components/product-detail/product-info";
import {
ColorImageCarouselItems,
ColorImageGrid,
ProductMedia,
} from "@/components/product-detail/product-media";
import { ProductMedia } from "@/components/product-detail/product-media";
import { ProductPrice } from "@/components/product-detail/product-price";
import { ProductSchema } from "@/components/product-detail/schema";
import { VariantSelectionProvider } from "@/components/product-detail/variant-selection-client";
import { BreadcrumbSchema } from "@/components/schema/breadcrumb-schema";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand All @@ -28,7 +26,6 @@ import { shopConfig } from "@/lib/config";
import type { Locale } from "@/lib/i18n";
import {
defaultSelectedOptions,
getSelectedColorImage,
getSharedImages,
hasColorImagePartitioning,
type SelectedOptions,
Expand Down Expand Up @@ -77,15 +74,20 @@ export async function ProductDetailSection({
{ name: product.title, path: `/products/${product.handle}` },
]}
/>
<div className="grid gap-10 lg:grid-cols-10 lg:items-start lg:gap-5">
<ProductMediaArea product={product} selectedOptionsPromise={selectedOptionsPromise} />
<ProductInfoArea
product={product}
selectedOptionsPromise={selectedOptionsPromise}
variantPromise={variantPromise}
locale={locale}
/>
</div>
<VariantSelectionProvider
handle={product.handle}
selectedOptionsPromise={selectedOptionsPromise}
>
<div className="grid gap-10 lg:grid-cols-10 lg:items-start lg:gap-5">
<ProductMediaArea product={product} selectedOptionsPromise={selectedOptionsPromise} />
<ProductInfoArea
product={product}
selectedOptionsPromise={selectedOptionsPromise}
variantPromise={variantPromise}
locale={locale}
/>
</div>
</VariantSelectionProvider>
</NextIntlClientProvider>
);
}
Expand Down Expand Up @@ -146,9 +148,8 @@ async function ResolvedColorImageGrid({
product: ProductDetails;
selectedOptionsPromise: Promise<SelectedOptions>;
}) {
const image = getSelectedColorImage(product, await selectedOptionsPromise);
if (!image) return null;
return <ColorImageGrid images={[image]} title={product.title} />;
const selectedOptions = await selectedOptionsPromise;
return <OptimisticColorImage layout="grid" product={product} selectedOptions={selectedOptions} />;
}

async function ResolvedColorImageCarousel({
Expand All @@ -158,9 +159,10 @@ async function ResolvedColorImageCarousel({
product: ProductDetails;
selectedOptionsPromise: Promise<SelectedOptions>;
}) {
const image = getSelectedColorImage(product, await selectedOptionsPromise);
if (!image) return null;
return <ColorImageCarouselItems images={[image]} title={product.title} />;
const selectedOptions = await selectedOptionsPromise;
return (
<OptimisticColorImage layout="carousel" product={product} selectedOptions={selectedOptions} />
);
}

async function ProductInfoArea({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useRouter } from "next/navigation";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
useTransition,
} from "react";

import { buildOptionUrl, type SelectedOptions } from "@/lib/product";

interface VariantSelectionContextValue {
isPending: boolean;
optimisticOptions: SelectedOptions | null;
reconcile: (selectedOptions: SelectedOptions) => void;
selectOption: (selectedOptions: SelectedOptions, name: string, value: string) => void;
}

const VariantSelectionContext = createContext<VariantSelectionContextValue | null>(null);

interface VariantSelectionProviderProps {
children: ReactNode;
handle: string;
selectedOptionsPromise: Promise<SelectedOptions>;
}

export function VariantSelectionProvider({
children,
handle,
selectedOptionsPromise,
}: VariantSelectionProviderProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [optimisticOptions, setOptimisticOptions] = useState<SelectedOptions | null>(null);
const optimisticOptionsRef = useRef(optimisticOptions);

const reconcile = useCallback((selectedOptions: SelectedOptions) => {
const currentOptions = optimisticOptionsRef.current;
if (!currentOptions || JSON.stringify(currentOptions) !== JSON.stringify(selectedOptions))
return;
optimisticOptionsRef.current = null;
setOptimisticOptions(null);
}, []);

useEffect(() => {
let cancelled = false;
selectedOptionsPromise.then((selectedOptions) => {
if (!cancelled) reconcile(selectedOptions);
});
return () => {
cancelled = true;
};
}, [reconcile, selectedOptionsPromise]);

const selectOption = useCallback(
(selectedOptions: SelectedOptions, name: string, value: string) => {
const nextOptions = {
...(optimisticOptionsRef.current ?? selectedOptions),
[name]: value,
};
optimisticOptionsRef.current = nextOptions;
startTransition(() => {
setOptimisticOptions(nextOptions);
router.replace(buildOptionUrl(handle, nextOptions, name, value), { scroll: false });
});
},
[handle, router],
);

return (
<VariantSelectionContext value={{ isPending, optimisticOptions, reconcile, selectOption }}>
{children}
</VariantSelectionContext>
);
}

export function useVariantSelection() {
const context = useContext(VariantSelectionContext);
if (!context) throw new Error("useVariantSelection must be used within VariantSelectionProvider");
return context;
}