Loading...
Loading...
Compare original and translation side by side
variant-selectionStart here: Read the Data Flow section first - it explains how everything connects.
variant-selection入门指引: 请先阅读数据流向章节,它会解释各模块的关联关系。
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────┘getProductData()"use cache"searchParams?variant=getProductData()"use cache"searchParams?variant=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: /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 │
└───────────────────────────────────────────────────────────────────┘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 carouselProductGalleryImageProductGalleryImage// 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// 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// 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
/>// 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
/>onImageClick<ImageCarousel images={images} onImageClick={(index) => openLightbox(index)} />onImageClick<ImageCarousel images={images} onImageClick={(index) => openLightbox(index)} />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"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"| 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 |
| 模块部分 | 是否缓存 | 原因说明 |
|---|---|---|
| 商品数据 | ✅ 是 | 使用 |
| 画廊图片 | ✅ 是 | 派生自缓存数据 |
| 商品名称/描述 | ✅ 是 | 静态内容 |
| 变体模块 | ❌ 否 | 读取 |
| 商品价格 | ❌ 否 | 属于变体模块内容 |
undefinedundefinedundefinedundefined<ErrorBoundary FallbackComponent={VariantSectionError}>
<Suspense fallback={<VariantSectionSkeleton />}>
<VariantSectionDynamic ... />
</Suspense>
</ErrorBoundary><ErrorBoundary FallbackComponent={VariantSectionError}>
<Suspense fallback={<VariantSectionSkeleton />}>
<VariantSectionDynamic ... />
</Suspense>
</ErrorBoundary>async function addToCart() {
"use server";
try {
// ... checkout logic
} catch (error) {
console.error("Add to cart failed:", error);
// Graceful failure - no crash
}
}async function addToCart() {
"use server";
try {
// ... checkout logic
} catch (error) {
console.error("Add to cart failed:", error);
// Graceful failure - no crash
}
}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)ProductDetails.graphqlpnpm run generatepage.tsxProductAttributesProductDetails.graphqlpnpm run generatepage.tsxProductAttributesimage-carousel.tsx<button className="relative h-20 w-20 ..."> {/* Change h-20 w-20 */}image-carousel.tsx<button className="relative h-20 w-20 ..."> {/* Change h-20 w-20 */}sticky-bar.tsxconst SCROLL_THRESHOLD = 500; // Change this valuesticky-bar.tsxconst SCROLL_THRESHOLD = 500; // Change this valueVariantSectionDynamic{
isOnSale && <Badge variant="destructive">Sale</Badge>;
}VariantSectionDynamic{
isOnSale && <Badge variant="destructive">Sale</Badge>;
}ProductDetails.graphqlVariantDetailsFragment.graphqlProductDetails.graphqlVariantDetailsFragment.graphqlpnpm run generate # Regenerate typespnpm run generate # Regenerate typespnpm test src/ui/components/pdp # Run PDP testspnpm test src/ui/components/pdp # Run PDP tests// ❌ Bad - VariantSectionError defined in Server Component file
<ErrorBoundary FallbackComponent={VariantSectionError}>
// ✅ Good - VariantSectionError in separate file with "use client"
// See variant-section-error.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;// ❌ 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// ❌ 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>// ❌ Bad - breaks React reconciliation when images change
{images.map((img, index) => <Image key={index} ... />)}
// ✅ Good - stable key
{images.map((img) => <Image key={img.url} ... />)}// ❌ Bad - VariantSectionError defined in Server Component file
<ErrorBoundary FallbackComponent={VariantSectionError}>
// ✅ Good - VariantSectionError in separate file with "use client"
// See variant-section-error.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;// ❌ 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// ❌ 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>// ❌ Bad - breaks React reconciliation when images change
{images.map((img, index) => <Image key={index} ... />)}
// ✅ Good - stable key
{images.map((img) => <Image key={img.url} ... />)}