pdp

📁 saleor/storefront 📅 8 days ago
3
总安装量
3
周安装量
#60801
全站排名
安装命令
npx skills add https://github.com/saleor/storefront --skill pdp

Agent 安装分布

amp 3
github-copilot 3
codex 3
kimi-cli 3
gemini-cli 3
cursor 3

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

  1. Product data is cachedgetProductData() 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

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

  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

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 query
  • VariantDetailsFragment.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} ... />)}