pdp

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Product Detail Page (PDP)

商品详情页(PDP)

When to Use

适用场景

Use this skill when:
  • Modifying PDP layout or components
  • Working with the image gallery/carousel
  • Understanding caching and streaming architecture
  • Debugging add-to-cart issues
  • Adding new product information sections
For variant selection logic specifically, see
variant-selection
.
Start here: Read the Data Flow section first - it explains how everything connects.
在以下场景中使用本技能:
  • 修改PDP布局或组件
  • 开发图片画廊/轮播组件
  • 理解缓存与流式渲染架构
  • 调试加入购物车相关问题
  • 添加新的商品信息模块
若需了解变体选择逻辑,请查看
variant-selection
入门指引: 请先阅读数据流向章节,它会解释各模块的关联关系。

Architecture Overview

架构概览

┌─────────────────────────────────────────────────────────────────┐
│ page.tsx (Server Component)                                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────────┐    ┌────────────────────────────────────┐ │
│  │ ProductGallery   │    │ Product Info Column                │ │
│  │ (Client)         │    │                                    │ │
│  │                  │    │  <h1>Product Name</h1>  ← Static   │ │
│  │ • Swipe/arrows   │    │                                    │ │
│  │ • Thumbnails     │    │  ┌────────────────────────────┐   │ │
│  │ • LCP optimized  │    │  │ ErrorBoundary              │   │ │
│  │                  │    │  │  ┌──────────────────────┐  │   │ │
│  │                  │    │  │  │ Suspense             │  │   │ │
│  │                  │    │  │  │  VariantSection ←────│──│── Dynamic
│  │                  │    │  │  │  (Server Action)     │  │   │ │
│  │                  │    │  │  └──────────────────────┘  │   │ │
│  │                  │    │  └────────────────────────────┘   │ │
│  │                  │    │                                    │ │
│  │                  │    │  ProductAttributes  ← Static       │ │
│  └──────────────────┘    └────────────────────────────────────┘ │
│                                                                 │
│  Data: getProductData() with "use cache"  ← Cached 5 min       │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ page.tsx (Server Component)                                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────────┐    ┌────────────────────────────────────┐ │
│  │ ProductGallery   │    │ Product Info Column                │ │
│  │ (Client)         │    │                                    │ │
│  │                  │    │  <h1>Product Name</h1>  ← Static   │ │
│  │ • Swipe/arrows   │    │                                    │ │
│  │ • Thumbnails     │    │  ┌────────────────────────────┐   │ │
│  │ • LCP optimized  │    │  │ ErrorBoundary              │   │ │
│  │                  │    │  │  ┌──────────────────────┐  │   │ │
│  │                  │    │  │  │ Suspense             │  │   │ │
│  │                  │    │  │  │  VariantSection ←────│──│── Dynamic
│  │                  │    │  │  │  (Server Action)     │  │   │ │
│  │                  │    │  │  └──────────────────────┘  │   │ │
│  │                  │    │  └────────────────────────────┘   │ │
│  │                  │    │                                    │ │
│  │                  │    │  ProductAttributes  ← Static       │ │
│  └──────────────────┘    └────────────────────────────────────┘ │
│                                                                 │
│  Data: getProductData() with "use cache"  ← Cached 5 min       │
└─────────────────────────────────────────────────────────────────┘

Key Principles

核心原则

  1. Product data is cached -
    getProductData()
    uses
    "use cache"
    (5 min)
  2. Variant section is dynamic - Reads
    searchParams
    , streams via Suspense
  3. Gallery shows variant images - Changes based on
    ?variant=
    URL param
  4. Errors are contained - ErrorBoundary prevents full page crash
  1. 商品数据已缓存 -
    getProductData()
    使用
    "use cache"
    (缓存时长5分钟)
  2. 变体模块为动态渲染 - 读取
    searchParams
    ,通过Suspense实现流式渲染
  3. 画廊展示变体专属图片 - 根据
    ?variant=
    URL参数切换图片
  4. 错误隔离处理 - ErrorBoundary避免整页崩溃

Data Flow

数据流向

Read this first - understanding how data flows makes everything else click:
URL: /us/products/blue-shirt?variant=abc123
┌───────────────────────────────────────────────────────────────────┐
│ page.tsx                                                          │
│                                                                   │
│   1. getProductData("blue-shirt", "us")                           │
│      └──► "use cache" ──► GraphQL ──► Returns product + variants  │
│                                                                   │
│   2. searchParams.variant = "abc123"                              │
│      └──► Find variant ──► Get variant.media ──► Gallery images   │
│                                                                   │
│   3. Render page with:                                            │
│      • Gallery ──────────────────► Shows variant images           │
│      • <Suspense> ──► VariantSection streams in                   │
│                       └──► Reads searchParams (makes it dynamic)  │
│                       └──► Server Action: addToCart()             │
└───────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ User selects different variant (e.g., "Red")                      │
│                                                                   │
│   router.push("?variant=xyz789")                                  │
│      └──► URL changes                                             │
│      └──► Page re-renders with new searchParams                   │
│      └──► Gallery shows red variant images                        │
│      └──► VariantSection shows red variant selected               │
└───────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ User clicks "Add to bag"                                          │
│                                                                   │
│   <form action={addToCart}>                                       │
│      └──► Server Action executes                                  │
│      └──► Creates/updates checkout                                │
│      └──► revalidatePath("/cart")                                 │
│      └──► Cart drawer updates                                     │
└───────────────────────────────────────────────────────────────────┘
Why this matters:
  • Product data is cached (fast loads)
  • URL is the source of truth for variant selection
  • Gallery reacts to URL changes without client state
  • Server Actions handle mutations without API routes
请优先阅读此部分 - 理解数据流向后,其他内容会更容易掌握:
URL: /us/products/blue-shirt?variant=abc123
┌───────────────────────────────────────────────────────────────────┐
│ page.tsx                                                          │
│                                                                   │
│   1. getProductData("blue-shirt", "us")                           │
│      └──► "use cache" ──► GraphQL ──► Returns product + variants  │
│                                                                   │
│   2. searchParams.variant = "abc123"                              │
│      └──► Find variant ──► Get variant.media ──► Gallery images   │
│                                                                   │
│   3. Render page with:                                            │
│      • Gallery ──────────────────► Shows variant images           │
│      • <Suspense> ──► VariantSection streams in                   │
│                       └──► Reads searchParams (makes it dynamic)  │
│                       └──► Server Action: addToCart()             │
└───────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ User selects different variant (e.g., "Red")                      │
│                                                                   │
│   router.push("?variant=xyz789")                                  │
│      └──► URL changes                                             │
│      └──► Page re-renders with new searchParams                   │
│      └──► Gallery shows red variant images                        │
│      └──► VariantSection shows red variant selected               │
└───────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ User clicks "Add to bag"                                          │
│                                                                   │
│   <form action={addToCart}>                                       │
│      └──► Server Action executes                                  │
│      └──► Creates/updates checkout                                │
│      └──► revalidatePath("/cart")                                 │
│      └──► Cart drawer updates                                     │
└───────────────────────────────────────────────────────────────────┘
设计意义
  • 商品数据已缓存(加载速度更快)
  • URL是变体选择的唯一可信源
  • 画廊响应URL变化,无需客户端状态
  • Server Actions处理数据变更,无需API路由

File Structure

文件结构

src/app/[channel]/(main)/products/[slug]/
└── page.tsx                          # Main PDP page

src/ui/components/pdp/
├── index.ts                          # Public exports
├── product-gallery.tsx               # Gallery wrapper
├── variant-section-dynamic.tsx       # Variant selection + add to cart
├── variant-section-error.tsx         # Error fallback (Client Component)
├── add-to-cart.tsx                   # Add to cart button
├── sticky-bar.tsx                    # Mobile sticky add-to-cart
├── product-attributes.tsx            # Description/details accordion
└── variant-selection/                # Variant selection system
    └── ...                           # See variant-selection skill

src/ui/components/ui/
├── carousel.tsx                      # Embla carousel primitives
└── image-carousel.tsx                # Reusable image carousel
src/app/[channel]/(main)/products/[slug]/
└── page.tsx                          # Main PDP page

src/ui/components/pdp/
├── index.ts                          # Public exports
├── product-gallery.tsx               # Gallery wrapper
├── variant-section-dynamic.tsx       # Variant selection + add to cart
├── variant-section-error.tsx         # Error fallback (Client Component)
├── add-to-cart.tsx                   # Add to cart button
├── sticky-bar.tsx                    # Mobile sticky add-to-cart
├── product-attributes.tsx            # Description/details accordion
└── variant-selection/                # Variant selection system
    └── ...                           # See variant-selection skill

src/ui/components/ui/
├── carousel.tsx                      # Embla carousel primitives
└── image-carousel.tsx                # Reusable image carousel

Image Gallery

图片画廊

Features

功能特性

  • Mobile: Horizontal swipe (Embla Carousel) + dot indicators
  • Desktop: Arrow navigation (hover) + thumbnail strip
  • LCP optimized: First image server-rendered via
    ProductGalleryImage
  • Variant-aware: Shows variant-specific images when selected
  • 移动端:横向滑动(基于Embla Carousel)+ 圆点指示器
  • 桌面端:悬停显示箭头导航 + 缩略图栏
  • LCP优化:首图通过
    ProductGalleryImage
    实现服务端渲染
  • 变体感知:选中变体时自动展示对应专属图片

How Variant Images Work

变体图片实现逻辑

tsx
// In page.tsx
const selectedVariant = searchParams.variant
	? product.variants?.find((v) => v.id === searchParams.variant)
	: null;

const images = getGalleryImages(product, selectedVariant);
// Priority: variant.media → product.media → thumbnail
tsx
// In page.tsx
const selectedVariant = searchParams.variant
	? product.variants?.find((v) => v.id === searchParams.variant)
	: null;

const images = getGalleryImages(product, selectedVariant);
// Priority: variant.media → product.media → thumbnail

Customizing Gallery

画廊自定义配置

tsx
// image-carousel.tsx props
<ImageCarousel
	images={images}
	productName="..."
	showArrows={true} // Desktop arrow buttons
	showDots={true} // Mobile dot indicators
	showThumbnails={true} // Desktop thumbnail strip
	onImageClick={(i) => {}} // For future lightbox
/>
tsx
// image-carousel.tsx props
<ImageCarousel
	images={images}
	productName="..."
	showArrows={true} // Desktop arrow buttons
	showDots={true} // Mobile dot indicators
	showThumbnails={true} // Desktop thumbnail strip
	onImageClick={(i) => {}} // For future lightbox
/>

Adding Zoom/Lightbox (Future)

添加图片缩放/灯箱功能(未来规划)

Use the
onImageClick
callback:
tsx
<ImageCarousel images={images} onImageClick={(index) => openLightbox(index)} />
使用
onImageClick
回调:
tsx
<ImageCarousel images={images} onImageClick={(index) => openLightbox(index)} />

Caching Strategy

缓存策略

Data Fetching

数据获取

tsx
async function getProductData(slug: string, channel: string) {
	"use cache";
	cacheLife("minutes"); // 5 minute cache
	cacheTag(`product:${slug}`); // For on-demand revalidation

	return await executePublicGraphQL(ProductDetailsDocument, {
		variables: { slug, channel },
	});
}
Note:
executePublicGraphQL
fetches only publicly visible data, which is safe inside
"use cache"
functions. For user-specific queries, use
executeAuthenticatedGraphQL
(but NOT inside
"use cache"
).
tsx
async function getProductData(slug: string, channel: string) {
	"use cache";
	cacheLife("minutes"); // 5 minute cache
	cacheTag(`product:${slug}`); // For on-demand revalidation

	return await executePublicGraphQL(ProductDetailsDocument, {
		variables: { slug, channel },
	});
}
注意
executePublicGraphQL
仅获取公开可见数据,可安全用于
"use cache"
函数中。若需查询用户专属数据,请使用
executeAuthenticatedGraphQL
(但不可用于
"use cache"
函数内)。

What's Cached vs Dynamic

缓存与动态内容区分

PartCached?Why
Product data✅ Yes
"use cache"
directive
Gallery images✅ YesDerived from cached data
Product name/description✅ YesStatic content
Variant section❌ NoReads
searchParams
(dynamic)
Prices❌ NoPart of variant section
模块部分是否缓存原因说明
商品数据✅ 是使用
"use cache"
指令
画廊图片✅ 是派生自缓存数据
商品名称/描述✅ 是静态内容
变体模块❌ 否读取
searchParams
(动态数据)
商品价格❌ 否属于变体模块内容

On-Demand Revalidation

按需重新验证缓存

bash
undefined
bash
undefined

Revalidate specific product

Revalidate specific product

curl "/api/revalidate?tag=product:my-product-slug"
undefined
curl "/api/revalidate?tag=product:my-product-slug"
undefined

Error Handling

错误处理

ErrorBoundary Pattern

ErrorBoundary模式

tsx
<ErrorBoundary FallbackComponent={VariantSectionError}>
  <Suspense fallback={<VariantSectionSkeleton />}>
    <VariantSectionDynamic ... />
  </Suspense>
</ErrorBoundary>
Why: If variant section throws, user still sees:
  • Product images ✅
  • Product name ✅
  • Description ✅
  • "Unable to load options. Try again." message
tsx
<ErrorBoundary FallbackComponent={VariantSectionError}>
  <Suspense fallback={<VariantSectionSkeleton />}>
    <VariantSectionDynamic ... />
  </Suspense>
</ErrorBoundary>
设计意义:若变体模块抛出错误,用户仍可正常查看:
  • 商品图片 ✅
  • 商品名称 ✅
  • 商品描述 ✅
  • 显示“无法加载选项,请重试”提示

Server Action Error Handling

Server Action错误处理

tsx
async function addToCart() {
	"use server";
	try {
		// ... checkout logic
	} catch (error) {
		console.error("Add to cart failed:", error);
		// Graceful failure - no crash
	}
}
tsx
async function addToCart() {
	"use server";
	try {
		// ... checkout logic
	} catch (error) {
		console.error("Add to cart failed:", error);
		// Graceful failure - no crash
	}
}

Add to Cart Flow

加入购物车流程

User clicks "Add to bag"
┌─────────────────────┐
│ form action={...}   │ ← HTML form submission
└─────────────────────┘
┌─────────────────────┐
│ addToCart()         │ ← Server Action
│ "use server"        │
│                     │
│ • Find/create cart  │
│ • Add line item     │
│ • revalidatePath()  │
└─────────────────────┘
┌─────────────────────┐
│ useFormStatus()     │ ← Shows "Adding..." state
│ pending: true       │
└─────────────────────┘
   Cart drawer updates (via revalidation)
User clicks "Add to bag"
┌─────────────────────┐
│ form action={...}   │ ← HTML form submission
└─────────────────────┘
┌─────────────────────┐
│ addToCart()         │ ← Server Action
│ "use server"        │
│                     │
│ • Find/create cart  │
│ • Add line item     │
│ • revalidatePath()  │
└─────────────────────┘
┌─────────────────────┐
│ useFormStatus()     │ ← Shows "Adding..." state
│ pending: true       │
└─────────────────────┘
   Cart drawer updates (via revalidation)

Common Tasks

常见任务

Add new product attribute display

添加新的商品属性展示

  1. Check
    ProductDetails.graphql
    for field
  2. If missing, add and run
    pnpm run generate
  3. Extract in
    page.tsx
    helper function
  4. Pass to
    ProductAttributes
    component
  1. 检查
    ProductDetails.graphql
    中是否存在对应字段
  2. 若不存在,添加字段并执行
    pnpm run generate
  3. page.tsx
    的辅助函数中提取该字段
  4. 将字段传递给
    ProductAttributes
    组件

Change gallery thumbnail size

修改画廊缩略图尺寸

Edit
image-carousel.tsx
:
tsx
<button className="relative h-20 w-20 ...">  {/* Change h-20 w-20 */}
编辑
image-carousel.tsx
tsx
<button className="relative h-20 w-20 ...">  {/* Change h-20 w-20 */}

Change sticky bar scroll threshold

修改移动端悬浮栏滚动阈值

Edit
sticky-bar.tsx
:
tsx
const SCROLL_THRESHOLD = 500; // Change this value
编辑
sticky-bar.tsx
tsx
const SCROLL_THRESHOLD = 500; // Change this value

Add product badges (New, Sale, etc.)

添加商品标签(新品、促销等)

Badges are in
VariantSectionDynamic
:
tsx
{
	isOnSale && <Badge variant="destructive">Sale</Badge>;
}
标签逻辑位于
VariantSectionDynamic
中:
tsx
{
	isOnSale && <Badge variant="destructive">Sale</Badge>;
}

GraphQL

GraphQL相关

Key Queries

核心查询

  • ProductDetails.graphql
    - Main product query
  • VariantDetailsFragment.graphql
    - Variant data including media
  • ProductDetails.graphql
    - 主商品查询
  • VariantDetailsFragment.graphql
    - 包含媒体信息的变体数据查询

After GraphQL Changes

GraphQL变更后操作

bash
pnpm run generate  # Regenerate types
bash
pnpm run generate  # Regenerate types

Testing

测试

bash
pnpm test src/ui/components/pdp  # Run PDP tests
bash
pnpm test src/ui/components/pdp  # Run PDP tests

Manual Testing Checklist

手动测试清单

  • Gallery swipe works on mobile
  • Arrows appear on desktop hover
  • Variant selection updates URL
  • Variant images change when variant selected
  • Add to cart shows pending state
  • Sticky bar appears after scroll
  • Error boundary catches failures
  • 移动端画廊滑动功能正常
  • 桌面端悬停时显示箭头导航
  • 变体选择后URL同步更新
  • 变体切换时图片同步变更
  • 加入购物车时显示加载状态
  • 滚动后悬浮栏正常显示
  • ErrorBoundary可捕获并处理错误

Anti-patterns

反模式

Don't pass Server Component functions to Client Components
tsx
// ❌ Bad - VariantSectionError defined in Server Component file
<ErrorBoundary FallbackComponent={VariantSectionError}>

// ✅ Good - VariantSectionError in separate file with "use client"
// See variant-section-error.tsx
Don't read searchParams in cached functions
tsx
// ❌ Bad - breaks caching
async function getProductData(slug: string, searchParams: SearchParams) {
  "use cache";
  const variant = searchParams.variant; // Dynamic data in cache!
}

// ✅ Good - read searchParams in page, pass result to cached function
const product = await getProductData(slug, channel);
const variant = searchParams.variant ? product.variants.find(...) : null;
Don't use useState for variant selection
tsx
// ❌ Bad - client state, not shareable, lost on refresh
const [selectedVariant, setSelectedVariant] = useState(null);

// ✅ Good - URL is source of truth
router.push(`?variant=${variantId}`);
// Read from searchParams on server
Don't skip ErrorBoundary around Suspense
tsx
// ❌ Bad - error crashes entire page
<Suspense fallback={<Skeleton />}>
  <DynamicComponent />
</Suspense>

// ✅ Good - error contained, rest of page visible
<ErrorBoundary FallbackComponent={ErrorFallback}>
  <Suspense fallback={<Skeleton />}>
    <DynamicComponent />
  </Suspense>
</ErrorBoundary>
Don't use index as key for images
tsx
// ❌ Bad - breaks React reconciliation when images change
{images.map((img, index) => <Image key={index} ... />)}

// ✅ Good - stable key
{images.map((img) => <Image key={img.url} ... />)}
禁止将服务端组件函数传递给客户端组件
tsx
// ❌ Bad - VariantSectionError defined in Server Component file
<ErrorBoundary FallbackComponent={VariantSectionError}>

// ✅ Good - VariantSectionError in separate file with "use client"
// See variant-section-error.tsx
禁止在缓存函数中读取searchParams
tsx
// ❌ Bad - breaks caching
async function getProductData(slug: string, searchParams: SearchParams) {
  "use cache";
  const variant = searchParams.variant; // Dynamic data in cache!
}

// ✅ Good - read searchParams in page, pass result to cached function
const product = await getProductData(slug, channel);
const variant = searchParams.variant ? product.variants.find(...) : null;
禁止使用useState管理变体选择
tsx
// ❌ Bad - client state, not shareable, lost on refresh
const [selectedVariant, setSelectedVariant] = useState(null);

// ✅ Good - URL is source of truth
router.push(`?variant=${variantId}`);
// Read from searchParams on server
禁止在Suspense外层省略ErrorBoundary
tsx
// ❌ Bad - error crashes entire page
<Suspense fallback={<Skeleton />}>
  <DynamicComponent />
</Suspense>

// ✅ Good - error contained, rest of page visible
<ErrorBoundary FallbackComponent={ErrorFallback}>
  <Suspense fallback={<Skeleton />}>
    <DynamicComponent />
  </Suspense>
</ErrorBoundary>
禁止使用索引作为图片组件的key
tsx
// ❌ Bad - breaks React reconciliation when images change
{images.map((img, index) => <Image key={index} ... />)}

// ✅ Good - stable key
{images.map((img) => <Image key={img.url} ... />)}