Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions apps/template/components/product-card/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -66,6 +71,7 @@ interface ProductCardImageProps {
outOfStock?: boolean;
outOfStockText?: string;
fallbackTitle?: string;
aspectRatio?: ProductCardAspectRatio;
className?: string;
}

Expand All @@ -77,14 +83,16 @@ function ProductCardImage({
outOfStock = false,
outOfStockText,
fallbackTitle,
aspectRatio = "square",
className,
}: ProductCardImageProps) {
const hasSlideshow = images && images.length > 1;

return (
<div
data-slot="product-card-image"
className={cn("relative aspect-square overflow-hidden bg-muted group/image", className)}
data-aspect-ratio={aspectRatio}
className={cn("relative overflow-hidden bg-muted group/image", aspectRatioClasses, className)}
>
{src ? (
<Image src={src} alt={alt} fill className="object-cover" sizes={sizes} />
Expand Down Expand Up @@ -182,23 +190,34 @@ function ProductCardPrice({
);
}

function ProductCardSkeleton({ className }: { className?: string }) {
function ProductCardSkeleton({
aspectRatio = "square",
className,
}: {
aspectRatio?: ProductCardAspectRatio;
className?: string;
}) {
return (
<div
data-slot="product-card-skeleton"
className={cn("flex flex-col overflow-hidden", className)}
>
<div className="aspect-square bg-muted animate-pulse" />
<div className="py-2.5">
<div
data-aspect-ratio={aspectRatio}
className={cn("bg-muted animate-pulse", aspectRatioClasses)}
/>
<div className="py-2.5 h-18 box-content grid gap-2">
<div className="h-4 w-full bg-muted animate-pulse" />
<div className="h-4 w-12 bg-muted animate-pulse mt-2" />
<div className="h-4 w-3/4 bg-muted animate-pulse" />
<div className="h-4 w-12 bg-muted animate-pulse" />
</div>
</div>
);
}

export {
ProductCard,
type ProductCardAspectRatio,
ProductCardBadge,
ProductCardContent,
ProductCardImage,
Expand Down
4 changes: 4 additions & 0 deletions apps/template/components/product-card/product-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Locale } from "@/lib/i18n";
import type { ProductCard as ProductCardType } from "@/lib/types";

import {
type ProductCardAspectRatio,
ProductCardBadge,
ProductCardContent,
ProductCardImage,
Expand All @@ -18,6 +19,7 @@ import {
export interface ProductCardProps {
product: ProductCardType;
locale: Locale;
aspectRatio?: ProductCardAspectRatio;
variant?: "default" | "featured";
outOfStockText?: string;
sizes?: string;
Expand All @@ -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",
Expand Down Expand Up @@ -61,6 +64,7 @@ export async function ProductCard({
outOfStock={!product.availableForSale}
outOfStockText={outOfStockText}
fallbackTitle={product.title}
aspectRatio={aspectRatio}
/>
<ProductCardContent>
<ProductCardTitle>{product.title}</ProductCardTitle>
Expand Down
16 changes: 14 additions & 2 deletions apps/template/components/product/products-slider.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<Slider>
Expand All @@ -29,7 +36,12 @@ export async function ProductsSlider({ title, products, locale }: ProductsSlider
<SliderContent>
{products.map((product) => (
<SliderItem key={product.id}>
<ProductCard product={product} locale={locale} outOfStockText={t("outOfStock")} />
<ProductCard
product={product}
locale={locale}
outOfStockText={t("outOfStock")}
aspectRatio={aspectRatio}
/>
</SliderItem>
))}
</SliderContent>
Expand Down
22 changes: 11 additions & 11 deletions apps/template/components/product/related-products-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="overflow-x-clip">
<div className="mx-auto min-w-0 grid gap-4">
<h2 className="text-2xl sm:text-3xl font-semibold tracking-tighter">{title}</h2>
<div className="grid grid-flow-col gap-5 relative left-1/2 right-1/2 -ml-[50vw] -mr-[50vw] w-screen max-w-none auto-cols-[58.33vw] px-5 sm:left-auto sm:right-auto sm:mx-0 sm:w-full sm:max-w-full sm:auto-cols-[calc((100%-1rem)/2)] sm:px-0 lg:auto-cols-[calc((100%-2rem)/3)] xl:auto-cols-[calc((100%-3rem)/4)]">
{["a", "b", "c", "d"].map((key) => (
<div key={key}>
<Skeleton className="aspect-square rounded-none" />
<div className="py-2.5 h-18 box-content grid gap-2">
<Skeleton className="h-4 w-full rounded-none" />
<Skeleton className="h-4 w-3/4 rounded-none" />
<Skeleton className="h-4 w-16 rounded-none" />
</div>
</div>
<ProductCardSkeleton key={key} aspectRatio={aspectRatio} />
))}
</div>
</div>
Expand Down Expand Up @@ -50,7 +50,7 @@ export async function RelatedProductsSection({
}) {
const t = await getTranslations("product");
return (
<Suspense fallback={<Fallback title={t("recommendations")} />}>
<Suspense fallback={<RelatedProductsSectionSkeleton title={t("recommendations")} />}>
<Render handle={handle} locale={locale} />
</Suspense>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<ProductCardSkeleton aspectRatio={…} />` 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.