pdp
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseProduct 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-selectionStart 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
核心原则
- Product data is cached - uses
getProductData()(5 min)"use cache" - Variant section is dynamic - Reads , streams via Suspense
searchParams - Gallery shows variant images - Changes based on URL param
?variant= - Errors are contained - ErrorBoundary prevents full page crash
- 商品数据已缓存 - 使用
getProductData()(缓存时长5分钟)"use cache" - 变体模块为动态渲染 - 读取,通过Suspense实现流式渲染
searchParams - 画廊展示变体专属图片 - 根据URL参数切换图片
?variant= - 错误隔离处理 - 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 carouselsrc/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 carouselImage 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 → thumbnailtsx
// 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 → thumbnailCustomizing 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 callback:
onImageClicktsx
<ImageCarousel images={images} onImageClick={(index) => openLightbox(index)} />使用回调:
onImageClicktsx
<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: fetches only publicly visible data, which is safe inside functions. For user-specific queries, use (but NOT inside ).
executePublicGraphQL"use cache"executeAuthenticatedGraphQL"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
缓存与动态内容区分
| Part | Cached? | Why |
|---|---|---|
| Product data | ✅ Yes | |
| Gallery images | ✅ Yes | Derived from cached data |
| Product name/description | ✅ Yes | Static content |
| Variant section | ❌ No | Reads |
| Prices | ❌ No | Part of variant section |
| 模块部分 | 是否缓存 | 原因说明 |
|---|---|---|
| 商品数据 | ✅ 是 | 使用 |
| 画廊图片 | ✅ 是 | 派生自缓存数据 |
| 商品名称/描述 | ✅ 是 | 静态内容 |
| 变体模块 | ❌ 否 | 读取 |
| 商品价格 | ❌ 否 | 属于变体模块内容 |
On-Demand Revalidation
按需重新验证缓存
bash
undefinedbash
undefinedRevalidate specific product
Revalidate specific product
curl "/api/revalidate?tag=product:my-product-slug"
undefinedcurl "/api/revalidate?tag=product:my-product-slug"
undefinedError 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
添加新的商品属性展示
- Check for field
ProductDetails.graphql - If missing, add and run
pnpm run generate - Extract in helper function
page.tsx - Pass to component
ProductAttributes
- 检查中是否存在对应字段
ProductDetails.graphql - 若不存在,添加字段并执行
pnpm run generate - 在的辅助函数中提取该字段
page.tsx - 将字段传递给组件
ProductAttributes
Change gallery thumbnail size
修改画廊缩略图尺寸
Edit :
image-carousel.tsxtsx
<button className="relative h-20 w-20 ..."> {/* Change h-20 w-20 */}编辑:
image-carousel.tsxtsx
<button className="relative h-20 w-20 ..."> {/* Change h-20 w-20 */}Change sticky bar scroll threshold
修改移动端悬浮栏滚动阈值
Edit :
sticky-bar.tsxtsx
const SCROLL_THRESHOLD = 500; // Change this value编辑:
sticky-bar.tsxtsx
const SCROLL_THRESHOLD = 500; // Change this valueAdd product badges (New, Sale, etc.)
添加商品标签(新品、促销等)
Badges are in :
VariantSectionDynamictsx
{
isOnSale && <Badge variant="destructive">Sale</Badge>;
}标签逻辑位于中:
VariantSectionDynamictsx
{
isOnSale && <Badge variant="destructive">Sale</Badge>;
}GraphQL
GraphQL相关
Key Queries
核心查询
- - Main product query
ProductDetails.graphql - - Variant data including media
VariantDetailsFragment.graphql
- - 主商品查询
ProductDetails.graphql - - 包含媒体信息的变体数据查询
VariantDetailsFragment.graphql
After GraphQL Changes
GraphQL变更后操作
bash
pnpm run generate # Regenerate typesbash
pnpm run generate # Regenerate typesTesting
测试
bash
pnpm test src/ui/components/pdp # Run PDP testsbash
pnpm test src/ui/components/pdp # Run PDP testsManual 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} ... />)}