Make product card row alignment fully dynamic via CSS Grid subgrid

Replaces the fixed line-clamp-2 subline cap with real subgrid alignment:
each grid (ProductGrid/RelatedProducts/MerklisteGrid) declares 6 explicit
row-tracks (image/header/price/belowPrice/lowstock/button, last one
minmax(0,1fr)), and every card spans those same 6 tracks via
grid-template-rows: subgrid — so row heights are genuinely shared across
a row of cards. A subline can now wrap to any number of lines (3, 4, ...)
without being truncated, and the price row still starts at the same Y on
every card in that row.

ProductCard.tsx is flattened from nested flex divs into 6 direct grid-row
children so each one can be its own subgrid track; the old flex-1 spacer
is replaced by self-end on the button's row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
This commit is contained in:
Marco
2026-08-26 23:49:50 +00:00
parent 8f006b6603
commit 98b3b3b6da
4 changed files with 77 additions and 65 deletions
+51 -48
View File
@@ -54,8 +54,20 @@ export function ProductCard({
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
// 6 direct grid rows (image / header / price / belowPrice / lowstock /
// button) instead of the old flex column — a plain flex-1 spacer only
// pins the button to the bottom of THIS card, it can't make the price
// row start at the same Y as a row sibling whose header block is a
// different height (e.g. a 2-line vs 1-line subline). grid-template-
// rows: subgrid pulls in the row-tracks each caller's grid container
// already declares (see ProductGrid.tsx's own comment) so every row is
// genuinely shared/max'd across the row of cards — dynamic, no
// line-clamp/truncation needed on the subline. gap-3 is used for every
// row gap including after the image (was pt-4/1rem there before —
// 0.25rem less, accepted for one consistent grid gap instead of mixed
// margin/gap spacing).
<div
className={`group bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1 ${className}`}
className={`group bg-bg-base border border-border rounded-md overflow-hidden grid [grid-template-rows:subgrid] [grid-row:span_6] gap-3 transition-transform duration-300 hover:-translate-y-1 ${className}`}
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
{/* Link wraps only the image, not the whole header — WishlistButton
@@ -98,55 +110,46 @@ export function ProductCard({
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
)}
</div>
<div className="flex flex-col gap-3 items-start px-5 pb-5 pt-4 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
{/* line-clamp-1 on the name and a reserved+clamped 2-line slot
for the subline — without these, a longer name or a subline
that wraps to 2 lines pushes THAT card's price row down
relative to its row siblings; the flex-1 spacer near the
button only re-aligns the button, not the price above it. */}
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h4 text-text-primary w-full line-clamp-1 hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full line-clamp-1" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
)}
<p className="text-body-sm text-text-muted w-full line-clamp-2 min-h-[2.75rem]">{product.subline}</p>
</div>
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
{belowPrice}
{/* Always rendered, text conditional — reserved height keeps every
card in a row equal height regardless of low-stock status. An
earlier version omitted this for fully-out-of-stock products
(this line can never apply there), on the theory that it was
dead space — but NotifyMeForm's single-row redesign (see its
own comment) now matches AddToCartInlineButton's button height
exactly, which made THIS line the only remaining source of a
mismatch: omitting it made the sold-out card shorter than its
siblings instead of taller. Keeping it unconditional is what
actually gets an exact match now. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether the title/category line wraps. */}
<div className="flex-1" />
<div className="flex flex-col gap-1 items-start w-full px-5">
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h4 text-text-primary w-full hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
)}
{product.subline && <p className="text-body-sm text-text-muted w-full">{product.subline}</p>}
</div>
<p className="flex items-baseline gap-1.5 px-5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
<div className="px-5">{belowPrice}</div>
{/* Always rendered, text conditional — an empty row still occupies
its shared track (0-height unless a row sibling needs it), same
reasoning as the price/header rows above. */}
<p className="text-label font-bold text-warning px-5">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* self-end pins the button to the bottom of this row even though
the row itself is minmax(0,1fr) (see grid-auto-rows) and can be
taller than the button — replaces the old flex-1 spacer. */}
<div className="self-end px-5 pb-5 w-full">
<AddToCartInlineButton
id={product.id}
numericId={product.numericId}