diff --git a/apps/template/components/product-card/components.tsx b/apps/template/components/product-card/components.tsx index a3517aa5..3c93e6e2 100644 --- a/apps/template/components/product-card/components.tsx +++ b/apps/template/components/product-card/components.tsx @@ -101,7 +101,7 @@ function ProductCardImage({ {fallbackTitle} )} - {hasSlideshow && } + {hasSlideshow && !outOfStock && } {outOfStock && (
@@ -216,6 +216,7 @@ function ProductCardSkeleton({ } export { + aspectRatioClasses, ProductCard, type ProductCardAspectRatio, ProductCardBadge, diff --git a/apps/template/components/product-detail/product-detail-page.tsx b/apps/template/components/product-detail/product-detail-page.tsx index 6eb08db5..6c49f0c6 100644 --- a/apps/template/components/product-detail/product-detail-page.tsx +++ b/apps/template/components/product-detail/product-detail-page.tsx @@ -1,5 +1,9 @@ import { Suspense } from "react"; +import { + aspectRatioClasses, + type ProductCardAspectRatio, +} from "@/components/product-card/components"; import { ProductSchema } from "@/components/product-detail/schema"; import { RelatedProductsSection } from "@/components/product/related-products-section"; import { BreadcrumbSchema } from "@/components/schema/breadcrumb-schema"; @@ -10,6 +14,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { siteConfig } from "@/lib/config"; import type { Locale } from "@/lib/i18n"; import type { ProductDetails } from "@/lib/types"; +import { cn } from "@/lib/utils"; import { ProductDetailSection } from "./product-detail-section"; @@ -24,22 +29,21 @@ function ProductBreadcrumbSchema({ title, handle }: { title: string; handle: str ); } -function ProductPageFallback() { +function ProductPageFallback({ aspectRatio }: { aspectRatio: ProductCardAspectRatio }) { + const tile = cn("w-full rounded-none", aspectRatioClasses); return (
- {/* Mobile: single full-bleed square + pagination space */}
- +
- {/* Desktop: 2×2 grid */}
- - - - + + + +
@@ -55,10 +59,12 @@ async function ProductContent({ productPromise, locale, variantIdPromise, + aspectRatio, }: { productPromise: Promise; locale: Locale; variantIdPromise: Promise; + aspectRatio: ProductCardAspectRatio; }) { const product = await productPromise; const { handle, title } = product; @@ -86,6 +92,7 @@ async function ProductContent({ product={product} locale={locale} variantIdPromise={variantIdPromise} + aspectRatio={aspectRatio} /> @@ -97,19 +104,22 @@ export async function ProductDetailPage({ productPromise, locale, variantIdPromise, + aspectRatio = "square", }: { productPromise: Promise; locale: Locale; variantIdPromise: Promise; + aspectRatio?: ProductCardAspectRatio; }) { return ( - }> + }> diff --git a/apps/template/components/product-detail/product-detail-section.tsx b/apps/template/components/product-detail/product-detail-section.tsx index aa40f70f..7a00918a 100644 --- a/apps/template/components/product-detail/product-detail-section.tsx +++ b/apps/template/components/product-detail/product-detail-section.tsx @@ -1,6 +1,10 @@ import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; +import { + aspectRatioClasses, + type ProductCardAspectRatio, +} from "@/components/product-card/components"; import { BuyButtons } from "@/components/product-detail/buy-buttons"; import { ProductInfoDescription, @@ -36,10 +40,12 @@ export async function ProductDetailSection({ product, locale, variantIdPromise, + aspectRatio = "square", }: { product: ProductDetails; locale: Locale; variantIdPromise: Promise; + aspectRatio?: ProductCardAspectRatio; }) { const { handle, title, featuredImage, images, videos, variants, options } = product; @@ -66,15 +72,28 @@ export async function ProductDetailSection({ otherImages={getSharedImages(images, options, variants)} videos={videos} title={title} + aspectRatio={aspectRatio} className="lg:col-span-6" desktopSlot={ - - - - + + + + } > @@ -83,6 +102,7 @@ export async function ProductDetailSection({ options={options} variants={variants} title={title} + aspectRatio={aspectRatio} variantIdPromise={variantIdPromise} /> @@ -90,7 +110,13 @@ export async function ProductDetailSection({ mobileSlot={ +
} @@ -100,6 +126,7 @@ export async function ProductDetailSection({ options={options} variants={variants} title={title} + aspectRatio={aspectRatio} variantIdPromise={variantIdPromise} />
@@ -110,6 +137,7 @@ export async function ProductDetailSection({ otherImages={images} videos={videos} title={title} + aspectRatio={aspectRatio} className="lg:col-span-6" /> )} @@ -297,12 +325,14 @@ async function ResolvedColorImages({ options, variants, title, + aspectRatio, variantIdPromise, }: { images: ImageType[]; options: ProductOption[]; variants: ProductVariant[]; title: string; + aspectRatio: ProductCardAspectRatio; variantIdPromise: Promise; }) { const variantId = await variantIdPromise; @@ -316,7 +346,7 @@ async function ResolvedColorImages({ if (colorImages.length === 0) return null; - return ; + return ; } async function ResolvedColorCarouselImages({ @@ -324,12 +354,14 @@ async function ResolvedColorCarouselImages({ options, variants, title, + aspectRatio, variantIdPromise, }: { images: ImageType[]; options: ProductOption[]; variants: ProductVariant[]; title: string; + aspectRatio: ProductCardAspectRatio; variantIdPromise: Promise; }) { const variantId = await variantIdPromise; @@ -343,5 +375,5 @@ async function ResolvedColorCarouselImages({ if (colorImages.length === 0) return null; - return ; + return ; } diff --git a/apps/template/components/product-detail/product-media.tsx b/apps/template/components/product-detail/product-media.tsx index 620d861c..ff10fce5 100644 --- a/apps/template/components/product-detail/product-media.tsx +++ b/apps/template/components/product-detail/product-media.tsx @@ -4,6 +4,10 @@ import { useTranslations } from "next-intl"; import Image from "next/image"; import { useEffect, useRef, useState } from "react"; +import { + aspectRatioClasses, + type ProductCardAspectRatio, +} from "@/components/product-card/components"; import { AutoPlayVideo } from "@/components/ui/auto-play-video"; import type { Image as ImageType, Video } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -22,6 +26,7 @@ function MediaImage({ idx, sizes, priority, + eager, className, }: { item: Extract; @@ -29,6 +34,7 @@ function MediaImage({ idx: number; sizes: string; priority: boolean; + eager: boolean; className?: string; }) { return ( @@ -39,7 +45,7 @@ function MediaImage({ className={cn("object-cover", className)} sizes={sizes} priority={priority} - loading={priority ? "eager" : "lazy"} + loading={priority || eager ? "eager" : "lazy"} draggable={false} /> ); @@ -78,10 +84,14 @@ function MediaVideo({ function Carousel({ mediaItems, title, + aspectRatio, + hasColorSlot, children, }: { mediaItems: MediaItem[]; title: string; + aspectRatio: ProductCardAspectRatio; + hasColorSlot: boolean; children?: React.ReactNode; }) { const [selectedIndex, setSelectedIndex] = useState(0); @@ -134,24 +144,33 @@ function Carousel({ style={{ scrollbarWidth: "none", msOverflowStyle: "none" }} > {children} - {mediaItems.map((item, idx) => ( -
- {item.type === "video" ? ( - - ) : ( - - )} -
- ))} + {mediaItems.map((item, idx) => { + const priority = !hasColorSlot && idx === 0; + const eager = hasColorSlot ? idx === 0 : idx === 1; + return ( +
+ {item.type === "video" ? ( + + ) : ( + + )} +
+ ); + })}
{/* Dot indicators – reserve space but hide when there's only one image */} @@ -175,11 +194,32 @@ function Carousel({ ); } -function GridItem({ item, title, idx }: { item: MediaItem; title: string; idx: number }) { +function GridItem({ + item, + title, + idx, + aspectRatio, + priority, + eager, +}: { + item: MediaItem; + title: string; + idx: number; + aspectRatio: ProductCardAspectRatio; + priority: boolean; + eager: boolean; +}) { return ( -
+
{item.type === "video" ? ( - + ) : ( )} @@ -199,19 +240,35 @@ function GridItem({ item, title, idx }: { item: MediaItem; title: string; idx: n function Grid({ mediaItems, title, + aspectRatio, + hasColorSlot, children, }: { mediaItems: MediaItem[]; title: string; + aspectRatio: ProductCardAspectRatio; + hasColorSlot: boolean; children?: React.ReactNode; }) { return (
{children} - {mediaItems.map((item, idx) => ( - - ))} + {mediaItems.map((item, idx) => { + const priority = !hasColorSlot && idx === 0; + const eager = hasColorSlot ? idx === 0 : idx === 1; + return ( + + ); + })}
); @@ -221,9 +278,25 @@ function Grid({ * Renders color-specific images as grid items (desktop). * Designed to be used inside a Suspense boundary as children of ProductMedia. */ -export function ColorImageGrid({ images, title }: { images: ImageType[]; title: string }) { +export function ColorImageGrid({ + images, + title, + aspectRatio, +}: { + images: ImageType[]; + title: string; + aspectRatio: ProductCardAspectRatio; +}) { return images.map((image, idx) => ( - + )); } @@ -231,29 +304,47 @@ export function ColorImageGrid({ images, title }: { images: ImageType[]; title: * Renders color-specific images as carousel items (mobile). * Matches the Carousel item structure for consistent snap-scroll behavior. */ -export function ColorImageCarouselItems({ images, title }: { images: ImageType[]; title: string }) { - return images.map((image, idx) => ( -
- {image.altText -
- )); +export function ColorImageCarouselItems({ + images, + title, + aspectRatio, +}: { + images: ImageType[]; + title: string; + aspectRatio: ProductCardAspectRatio; +}) { + return images.map((image, idx) => { + const priority = idx === 0; + const eager = idx === 1; + return ( +
+ {image.altText +
+ ); + }); } export function ProductMedia({ otherImages, videos, title, + aspectRatio = "square", className, desktopSlot, mobileSlot, @@ -261,6 +352,7 @@ export function ProductMedia({ otherImages: ImageType[]; videos: Video[]; title: string; + aspectRatio?: ProductCardAspectRatio; className?: string; /** Color images rendered as grid items (desktop). */ desktopSlot?: React.ReactNode; @@ -274,15 +366,27 @@ export function ProductMedia({ if (sharedMediaItems.length === 0 && !desktopSlot && !mobileSlot) return null; + const hasColorSlot = !!mobileSlot || !!desktopSlot; + return (
- + {mobileSlot}
- + {desktopSlot}
diff --git a/packages/plugin/template-rollout-log/2026-05-04-pdp-aspect-ratio-and-oos-slideshow.md b/packages/plugin/template-rollout-log/2026-05-04-pdp-aspect-ratio-and-oos-slideshow.md new file mode 100644 index 00000000..3e1833ac --- /dev/null +++ b/packages/plugin/template-rollout-log/2026-05-04-pdp-aspect-ratio-and-oos-slideshow.md @@ -0,0 +1,50 @@ +--- +title: PDP gallery aspect ratio prop + suppress hover slideshow on out-of-stock cards +changeKey: pdp-aspect-ratio-and-oos-slideshow +introducedOn: 2026-05-04 +changeType: feature +defaultAction: adopt +appliesTo: + - all +paths: + - apps/template/components/product-card/components.tsx + - apps/template/components/product-detail/product-media.tsx + - apps/template/components/product-detail/product-detail-section.tsx + - apps/template/components/product-detail/product-detail-page.tsx +--- + +## Summary + +Two related edits, both layered on top of the earlier ProductCard `aspectRatio` prop (rolled out 2026-04-30): + +1. **PDP gallery now accepts `aspectRatio`.** `ProductDetailPage`, `ProductDetailSection`, and `ProductMedia` take an optional `aspectRatio: "landscape" | "portrait" | "square"` (default `"square"`). The mobile carousel items, the desktop 2×2 grid items, and the color-image slot helpers all pick the matching `aspect-*` class via the same `data-[aspect-ratio=…]` selector pattern as the product card. The Suspense fallback skeletons (page-level and color-slot-level) match. The lightbox modal is intentionally left alone — it still fills the viewport with `object-contain` so the full image is always visible at any aspect. + +2. **Out-of-stock cards no longer reveal the hover slideshow.** `ProductCardImage` previously rendered `ProductCardSlideshow` regardless of stock state. Because the OOS overlay is `bg-black/60` (translucent), users hovering an OOS card on `lg+` saw images cycling underneath the dim overlay. Slideshow rendering is now skipped when `outOfStock` is true, which also avoids the wasted `next/image` requests for the non-featured images. + +## Why it matters + +- The earlier rollout exposed `aspectRatio` on the card but not on the PDP gallery, so a storefront picking portrait or landscape product cards had no way to make the PDP match. This is a routine catalog ask and shouldn't have to be re-plumbed per fork. +- The OOS hover behavior was a polish bug — slideshow images cycling under a "Sold out" tint reads as broken state. Suppressing the slideshow in this case is the simplest fix and has no other side effects. + +## Apply when + +- The storefront renders `ProductDetailPage` (default) and either picks a non-square `aspectRatio` for product cards or wants the option to. +- Or the storefront renders `ProductCard` for products with multiple images and out-of-stock variants. + +## Safe to skip when + +- The storefront has already replaced `ProductMedia` or `ProductCardImage` with custom implementations. + +## Notes + +- `aspectRatioClasses` is now exported from `components/product-card/components.tsx` so reusing the same class string elsewhere (PDP gallery, page-level fallback) doesn't require duplicating the selector. +- The type stays named `ProductCardAspectRatio` even though it's now used outside product cards. Renaming would touch every existing consumer for no behavior gain. +- No call site needs updating — the default of `"square"` preserves existing rendering exactly. To change it, pass `aspectRatio="portrait"` (or `"landscape"`) to `` in `app/products/[handle]/page.tsx`. + +## Validation + +1. `pnpm --filter template lint` and `pnpm --filter template build` clean. +2. `pnpm --filter template dev`. Visit a PDP with multiple images. + - Default (`square`): gallery items unchanged from current. + - Pass `aspectRatio="portrait"` on `` and confirm the carousel slides and the 2×2 grid items become 3:4. Click a grid item — the lightbox modal still fills the viewport unchanged. +3. On a collection or search grid at `lg+`, hover an out-of-stock product card with multiple images. No slideshow images cycle behind the dim overlay. diff --git a/packages/plugin/template-rollout-log/2026-05-04-pdp-image-priority.md b/packages/plugin/template-rollout-log/2026-05-04-pdp-image-priority.md new file mode 100644 index 00000000..cf0a8001 --- /dev/null +++ b/packages/plugin/template-rollout-log/2026-05-04-pdp-image-priority.md @@ -0,0 +1,52 @@ +--- +title: PDP image loading — split priority from eager and account for the color slot +changeKey: pdp-image-priority +introducedOn: 2026-05-04 +changeType: fix +defaultAction: adopt +appliesTo: + - all +paths: + - apps/template/components/product-detail/product-media.tsx +--- + +## Summary + +The PDP gallery's `priority` handling had two problems that hurt LCP and over-reserved preload bandwidth: + +1. **Mobile carousel under-prioritized in color-partitioned mode.** The shared-items map used `priority={!children && idx === 0}`. Whenever a `mobileSlot` was passed (the resolved-color-images Suspense node), `children` was always truthy, so *no* shared item was marked priority — even if the resolved color images turned out to be empty. The mobile LCP candidate was lazy-loaded. +2. **Desktop grid double-prioritized.** `GridItem` used `priority={idx < 2}` independently for color images and shared items. With color partitioning on, the first two color images were priority *and* the first two shared items at visual positions 3/4 also got priority — four `` competing for a single PDP. + +This change separates `priority` (the single LCP candidate — `fetchPriority="high"` plus a preload link) from `loading="eager"` (above-the-fold but not LCP — loads immediately without fighting the LCP for bandwidth), and computes both centrally in `ProductMedia` based on a `hasColorSlot` flag. + +The rules are: + +- **Color slot present** → `colorImage[0]` is `priority`, `colorImage[1]` is `eager`. The first shared item is `eager` to cover the case where color partitioning resolves to zero or one images and the shared item is therefore the second (or first) visible. +- **No color slot** → `shared[0]` is `priority`, `shared[1]` is `eager`. + +`MediaImage` now accepts a separate `eager` prop so loading semantics aren't conflated with priority. `MediaVideo`'s preview image continues to use the combined `priority || eager` signal since the cost is small and the preview is what affects perceived load. + +## Why it matters + +- LCP: in color-partitioned mode with zero color images, the first shared image was lazy. Now it's eager (or priority where applicable). +- Bandwidth: only one image per PDP is now `priority`; the second visible is `eager` without a preload link. Browsers can prioritize correctly instead of seeing four equal-priority preloads. + +## Apply when + +- The storefront uses `ProductMedia`, `ColorImageGrid`, or `ColorImageCarouselItems` directly. + +## Safe to skip when + +- The storefront has replaced `ProductMedia` with a custom gallery that already routes priority through a single LCP candidate. + +## Notes + +- `MediaImage` now requires a `priority: boolean` and an `eager: boolean`. If you have callers outside `ProductMedia`, pass both explicitly. +- Videos still use the combined `priority || eager` signal for their preview image — the fix is image-focused. + +## Validation + +1. `pnpm --filter template lint` and `tsc --noEmit` clean. +2. `pnpm --filter template dev`. Open a PDP without color partitioning. In DevTools → Network, the first image is `fetchpriority=high` with a preload link in ``, the second is `loading=eager` (no preload), the rest are lazy. +3. Open a PDP with color partitioning (a product with multiple colors that have their own images). Confirm the first color image is the only `priority` request, the second color image is `eager`, and the first shared image is `eager`. No second `` for product images. +4. Edge case: a color-partitioned product where the selected color resolves to no color images. Confirm the first shared image still loads quickly (`eager`).