pdp
npx skills add https://github.com/saleor/storefront --skill pdp
Agent 安装分布
Skill 文档
Product Detail Page (PDP)
Sources: Next.js Caching · Server Actions · Suspense
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.
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 â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Key Principles
- Product data is cached –
getProductData()uses"use cache"(5 min) - Variant section is dynamic – Reads
searchParams, streams via Suspense - Gallery shows variant images – Changes based on
?variant=URL param - Errors are contained – ErrorBoundary prevents full page crash
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
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
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
How Variant Images Work
// 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
// 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:
<ImageCarousel images={images} onImageClick={(index) => openLightbox(index)} />
Caching Strategy
Data Fetching
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").
What’s Cached vs Dynamic
| Part | Cached? | Why |
|---|---|---|
| Product data | â Yes | "use cache" directive |
| Gallery images | â Yes | Derived from cached data |
| Product name/description | â Yes | Static content |
| Variant section | â No | Reads searchParams (dynamic) |
| Prices | â No | Part of variant section |
On-Demand Revalidation
# Revalidate specific product
curl "/api/revalidate?tag=product:my-product-slug"
Error Handling
ErrorBoundary Pattern
<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
Server Action Error Handling
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)
Common Tasks
Add new product attribute display
- Check
ProductDetails.graphqlfor field - If missing, add and run
pnpm run generate - Extract in
page.tsxhelper function - Pass to
ProductAttributescomponent
Change gallery thumbnail size
Edit image-carousel.tsx:
<button className="relative h-20 w-20 ..."> {/* Change h-20 w-20 */}
Change sticky bar scroll threshold
Edit sticky-bar.tsx:
const SCROLL_THRESHOLD = 500; // Change this value
Add product badges (New, Sale, etc.)
Badges are in VariantSectionDynamic:
{
isOnSale && <Badge variant="destructive">Sale</Badge>;
}
GraphQL
Key Queries
ProductDetails.graphql– Main product queryVariantDetailsFragment.graphql– Variant data including media
After GraphQL Changes
pnpm run generate # Regenerate types
Testing
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
Anti-patterns
â Don’t pass Server Component functions to Client Components
// â 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
// â 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
// â 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
// â 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
// â Bad - breaks React reconciliation when images change
{images.map((img, index) => <Image key={index} ... />)}
// â
Good - stable key
{images.map((img) => <Image key={img.url} ... />)}