From f01d96b5e58e725e27dae6264f58b9578680b856 Mon Sep 17 00:00:00 2001 From: Dan Laugharn Date: Thu, 30 Apr 2026 19:17:04 -0500 Subject: [PATCH] ProductCard: aspect ratio prop, skeleton sizing fix, dedup related skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related edits: 1. Add an optional aspectRatio prop (landscape | portrait | square, default square) to ProductCard, ProductCardSkeleton, and ProductsSlider. The image container picks the matching aspect-* class via a data-[aspect-ratio=...] selector. Existing call sites are unaffected. 2. Fix the skeleton's content area. The rendered card title is line-clamp-2 but the skeleton reserved height for one line plus a short price bar, so the layout jumped upward when data resolved. Three h-4 bars (two title, one price) inside py-2.5 h-18 box-content grid gap-2 now match the rendered card. 3. Collapse the duplicate skeleton in RelatedProductsSectionSkeleton. It was rendering its own inline tile that mirrored what ProductCardSkeleton does, and the two had drifted — that's how (2) crept in. With (2) fixed, the related-products fallback can render for each tile and stay in sync. The Fallback helper is renamed to RelatedProductsSectionSkeleton and exported so consumers can render it at a parent fallback (e.g. a page-level Suspense). Adds a template-rollout-log entry for downstream forks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/product-card/components.tsx | 29 +++++++++-- .../components/product-card/product-card.tsx | 4 ++ .../components/product/products-slider.tsx | 16 +++++- .../product/related-products-section.tsx | 22 ++++---- ...-product-card-aspect-ratio-and-skeleton.md | 52 +++++++++++++++++++ 5 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 packages/plugin/template-rollout-log/2026-04-30-product-card-aspect-ratio-and-skeleton.md diff --git a/apps/template/components/product-card/components.tsx b/apps/template/components/product-card/components.tsx index c8ceb80e..a3517aa5 100644 --- a/apps/template/components/product-card/components.tsx +++ b/apps/template/components/product-card/components.tsx @@ -58,6 +58,11 @@ function ProductCardImageContainer({ ); } +type ProductCardAspectRatio = "landscape" | "portrait" | "square"; + +const aspectRatioClasses = + "data-[aspect-ratio=landscape]:aspect-[4/3] data-[aspect-ratio=portrait]:aspect-[3/4] data-[aspect-ratio=square]:aspect-square"; + interface ProductCardImageProps { src?: string | null; alt: string; @@ -66,6 +71,7 @@ interface ProductCardImageProps { outOfStock?: boolean; outOfStockText?: string; fallbackTitle?: string; + aspectRatio?: ProductCardAspectRatio; className?: string; } @@ -77,6 +83,7 @@ function ProductCardImage({ outOfStock = false, outOfStockText, fallbackTitle, + aspectRatio = "square", className, }: ProductCardImageProps) { const hasSlideshow = images && images.length > 1; @@ -84,7 +91,8 @@ function ProductCardImage({ return (
{src ? ( {alt} @@ -182,16 +190,26 @@ function ProductCardPrice({ ); } -function ProductCardSkeleton({ className }: { className?: string }) { +function ProductCardSkeleton({ + aspectRatio = "square", + className, +}: { + aspectRatio?: ProductCardAspectRatio; + className?: string; +}) { return (
-
-
+
+
-
+
+
); @@ -199,6 +217,7 @@ function ProductCardSkeleton({ className }: { className?: string }) { export { ProductCard, + type ProductCardAspectRatio, ProductCardBadge, ProductCardContent, ProductCardImage, diff --git a/apps/template/components/product-card/product-card.tsx b/apps/template/components/product-card/product-card.tsx index 5a417da2..45ae0e5f 100644 --- a/apps/template/components/product-card/product-card.tsx +++ b/apps/template/components/product-card/product-card.tsx @@ -5,6 +5,7 @@ import type { Locale } from "@/lib/i18n"; import type { ProductCard as ProductCardType } from "@/lib/types"; import { + type ProductCardAspectRatio, ProductCardBadge, ProductCardContent, ProductCardImage, @@ -18,6 +19,7 @@ import { export interface ProductCardProps { product: ProductCardType; locale: Locale; + aspectRatio?: ProductCardAspectRatio; variant?: "default" | "featured"; outOfStockText?: string; sizes?: string; @@ -27,6 +29,7 @@ export interface ProductCardProps { export async function ProductCard({ product, locale, + aspectRatio = "square", variant = "default", outOfStockText, sizes = "(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw", @@ -61,6 +64,7 @@ export async function ProductCard({ outOfStock={!product.availableForSale} outOfStockText={outOfStockText} fallbackTitle={product.title} + aspectRatio={aspectRatio} /> {product.title} diff --git a/apps/template/components/product/products-slider.tsx b/apps/template/components/product/products-slider.tsx index 51e85f31..6de074d9 100644 --- a/apps/template/components/product/products-slider.tsx +++ b/apps/template/components/product/products-slider.tsx @@ -1,5 +1,6 @@ import { getTranslations } from "next-intl/server"; +import type { ProductCardAspectRatio } from "@/components/product-card/components"; import { ProductCard } from "@/components/product-card/product-card"; import { Slider, @@ -16,9 +17,15 @@ interface ProductsSliderProps { title: string; products: ProductCardType[]; locale: Locale; + aspectRatio?: ProductCardAspectRatio; } -export async function ProductsSlider({ title, products, locale }: ProductsSliderProps) { +export async function ProductsSlider({ + title, + products, + locale, + aspectRatio = "square", +}: ProductsSliderProps) { const t = await getTranslations("product"); return ( @@ -29,7 +36,12 @@ export async function ProductsSlider({ title, products, locale }: ProductsSlider {products.map((product) => ( - + ))} diff --git a/apps/template/components/product/related-products-section.tsx b/apps/template/components/product/related-products-section.tsx index 03906ae0..d71db47d 100644 --- a/apps/template/components/product/related-products-section.tsx +++ b/apps/template/components/product/related-products-section.tsx @@ -2,26 +2,26 @@ import { getTranslations } from "next-intl/server"; import { connection } from "next/server"; import { Suspense } from "react"; +import type { ProductCardAspectRatio } from "@/components/product-card/components"; +import { ProductCardSkeleton } from "@/components/product-card/product-card"; import { ProductsSlider } from "@/components/product/products-slider"; -import { Skeleton } from "@/components/ui/skeleton"; import type { Locale } from "@/lib/i18n"; import { getProductRecommendations } from "@/lib/shopify/operations/products"; -function Fallback({ title }: { title: string }) { +export function RelatedProductsSectionSkeleton({ + title, + aspectRatio = "square", +}: { + title: string; + aspectRatio?: ProductCardAspectRatio; +}) { return (

{title}

{["a", "b", "c", "d"].map((key) => ( -
- -
- - - -
-
+ ))}
@@ -50,7 +50,7 @@ export async function RelatedProductsSection({ }) { const t = await getTranslations("product"); return ( - }> + }> ); diff --git a/packages/plugin/template-rollout-log/2026-04-30-product-card-aspect-ratio-and-skeleton.md b/packages/plugin/template-rollout-log/2026-04-30-product-card-aspect-ratio-and-skeleton.md new file mode 100644 index 00000000..8f8e39aa --- /dev/null +++ b/packages/plugin/template-rollout-log/2026-04-30-product-card-aspect-ratio-and-skeleton.md @@ -0,0 +1,52 @@ +--- +title: Product card — aspect ratio prop, skeleton sizing, and skeleton dedup +changeKey: product-card-aspect-ratio-and-skeleton +introducedOn: 2026-04-30 +changeType: feature +defaultAction: adopt +appliesTo: + - all +paths: + - apps/template/components/product-card/components.tsx + - apps/template/components/product-card/product-card.tsx + - apps/template/components/product/products-slider.tsx + - apps/template/components/product/related-products-section.tsx +--- + +## Summary + +Three related edits in one change: + +1. **Aspect ratio prop on the product card.** `ProductCard`, `ProductCardSkeleton`, and `ProductsSlider` now accept an optional `aspectRatio: "landscape" | "portrait" | "square"` (default `"square"`). The image container picks the matching `aspect-*` class via a `data-[aspect-ratio=…]` selector. Existing call sites are unaffected — square stays the default. + +2. **`ProductCardSkeleton` reserves the right height.** The rendered card title is `line-clamp-2`, but the skeleton was reserving height for one title line plus a short price bar — so the layout shifted upward when data resolved. The skeleton now renders three `h-4` bars (two for the wrapping title, one for the price) inside `py-2.5 h-18 box-content grid gap-2`, matching the actual card. + +3. **Collapse the duplicate skeleton in `RelatedProductsSectionSkeleton`.** The related-products fallback was rendering its own inline tile that mirrored what `ProductCardSkeleton` does. Both implementations existed because the per-card skeleton drifted. With the height fix in (2), the related-products skeleton can render `` for each tile and stay in sync automatically. The local `Fallback` helper is renamed to `RelatedProductsSectionSkeleton` and exported so consumers can render it at a parent fallback (e.g. a page-level Suspense) when needed. + +## Why it matters + +- Aspect ratio is a routine catalog ask (portrait for furniture/apparel, landscape for some banners). The template having to grow this prop ad-hoc per project is friction that shouldn't recur. +- The skeleton height regression caused a one-line layout shift on every product grid the moment data resolved — this was the symptom that surfaced the issue. +- Two skeleton implementations drifted once and would drift again. Routing the related-products section through `ProductCardSkeleton` removes the surface area. + +## Apply when + +- The storefront uses `ProductCard`, `ProductCardSkeleton`, or `ProductsSlider` directly. +- Or the storefront renders `RelatedProductsSection` and notices a one-line layout jump when recommendations resolve. + +## Safe to skip when + +- The storefront has already replaced `ProductCardSkeleton` and the related-products fallback with custom skeletons sized to its own card. + +## Notes + +- The new `ProductCardAspectRatio` type is exported from `components/product-card/components.tsx`. +- `RelatedProductsSectionSkeleton` is now exported and accepts the same `aspectRatio` so a page-level fallback (rendered before the in-component Suspense fires) can reserve the right shape. + +## Validation + +1. `pnpm --filter template dev`. +2. Home page: confirm the featured-products grid renders without a vertical jump when products resolve. The skeleton bars should fill the same vertical space the title and price take after load. +3. PDP: open a product. The related-products fallback should also have no jump when recommendations resolve. +4. Pass `aspectRatio="portrait"` to a `ProductsSlider` instance and confirm the cards render at 3:4 with the matching skeleton shape. +5. `pnpm --filter template lint` and `tsc --noEmit` clean.