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
2 changes: 1 addition & 1 deletion apps/docs/content/docs/anatomy/pages/pdp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 23 additions & 14 deletions apps/template/components/product-detail/color-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import type * as React from "react";

Expand All @@ -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<ReturnType<typeof getTranslations<"product">>>;
export interface ProductOptionLabels {
selectVariant: Record<string, Record<string, string>>;
unavailableVariant: Record<string, Record<string, string>>;
}

interface ColorPickerProps extends React.ComponentProps<"div"> {
option: ProductOption;
selectedValue: string;
available: Set<string> | undefined;
existing?: Set<string>;
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) {
Expand All @@ -38,6 +44,7 @@ export function ColorPicker({
<div className="flex flex-wrap gap-2.5">
{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
Expand All @@ -53,12 +60,12 @@ export function ColorPicker({
/>
);

if (!isAvailable) {
if (!exists) {
return (
<span
key={value.id}
className="block cursor-not-allowed opacity-40"
aria-label={t("unavailableVariantLabel", { name: option.name, value: value.name })}
aria-label={labels.unavailableVariant[option.name]?.[value.name]}
>
{swatch}
</span>
Expand All @@ -69,9 +76,11 @@ export function ColorPicker({
<Link
key={value.id}
href={href}
replace
scroll={false}
className="block cursor-pointer"
aria-label={t("selectVariantLabel", { name: option.name, value: value.name })}
className={cn("block cursor-pointer", !isAvailable && "opacity-40")}
aria-label={labels.selectVariant[option.name]?.[value.name]}
onClick={onOptionSelect ? () => onOptionSelect(option.name, value.name) : undefined}
>
{swatch}
</Link>
Expand Down
27 changes: 20 additions & 7 deletions apps/template/components/product-detail/option-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | undefined;
existing?: Set<string>;
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) {
Expand All @@ -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.
Expand All @@ -52,7 +58,7 @@ export function OptionPicker({
</>
);

if (!isAvailable) {
if (!exists) {
return (
<span key={value.id} className={classes}>
{label}
Expand All @@ -61,7 +67,14 @@ export function OptionPicker({
}

return (
<Link key={value.id} href={href} scroll={false} className={classes}>
<Link
key={value.id}
href={href}
replace
scroll={false}
className={classes}
onClick={onOptionSelect ? () => onOptionSelect(option.name, value.name) : undefined}
>
{label}
</Link>
);
Expand Down
Loading