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:
@@ -129,9 +129,13 @@ export function RelatedProducts({
|
||||
(opacity: 0) — invisible. Not worth chasing a fix for a
|
||||
scroll-reveal nicety on a list that mutates; a static grid
|
||||
renders correctly with no animation risk. */}
|
||||
{/* No items-start — see ProductGrid.tsx's own comment on why that
|
||||
silently defeats ProductCard's h-full equal-height design. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{/* No items-start, and grid-auto-rows + subgrid on each card — see
|
||||
ProductGrid.tsx's own comment on why both are needed for
|
||||
ProductCard's internal rows (price, etc.) to actually line up
|
||||
across cards, not just overall card height. No RevealItem
|
||||
wrapper here (see comment above), so the subgrid classes go
|
||||
straight onto ProductCard's own className prop instead. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product, i) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
@@ -141,7 +145,7 @@ export function RelatedProducts({
|
||||
wishlistEnabled={wishlistEnabled}
|
||||
wishlistRevealOnHover
|
||||
className={
|
||||
"sm:col-span-4 " +
|
||||
"sm:col-span-4 [grid-row:span_6] " +
|
||||
// Center the row when there are fewer than 3 cards to show
|
||||
// (e.g. only 1 active product left once the others are
|
||||
// already in the cart) — only the first card needs an
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -39,17 +39,18 @@ export function MerklisteGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
// No items-start — see ProductGrid.tsx's own comment on why that
|
||||
// silently defeats ProductCard's h-full equal-height design.
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
|
||||
// No items-start, and grid-auto-rows + subgrid on each RevealItem —
|
||||
// see ProductGrid.tsx's own comment on why both are needed for
|
||||
// ProductCard's internal rows (price, etc.) to actually line up
|
||||
// across cards, not just overall card height.
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full">
|
||||
{visibleEntries.map(({ item, product }) => (
|
||||
<RevealItem key={`${product.id}-${item.variant}`}>
|
||||
<RevealItem key={`${product.id}-${item.variant}`} className="grid [grid-template-rows:subgrid] [grid-row:span_6]">
|
||||
<ProductCard
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled
|
||||
className="h-full"
|
||||
// Already-purchased takes precedence over the default
|
||||
// Ausverkauft/discount badge — a customer who already bought
|
||||
// this doesn't need a restock notice, they need to know they
|
||||
|
||||
@@ -115,17 +115,22 @@ export async function ProductGrid({
|
||||
stuck at opacity 0 — the grid going blank/white after
|
||||
applying a filter. */}
|
||||
{/* No items-start here (was set before, silently defeating
|
||||
ProductCard's own h-full + flex-1 spacer, built exactly for
|
||||
equal-height cards) — default items-stretch is what actually
|
||||
makes every card in a row match the tallest one, e.g. when
|
||||
one product's subline wraps to 2 lines and its neighbors'
|
||||
don't. */}
|
||||
ProductCard's own h-full, built exactly for equal-height
|
||||
cards) — default items-stretch is what makes every card in a
|
||||
row match the tallest one overall. That alone doesn't align
|
||||
each card's internal rows (e.g. the price sitting right
|
||||
after a subline that wraps to 2 lines on one card but 1 on
|
||||
its neighbor) — see ProductCard.tsx's own comment on the
|
||||
subgrid rows (grid-auto-rows below) that solves that part:
|
||||
each RevealItem here spans the same 6 row-tracks and passes
|
||||
them down via grid-template-rows: subgrid, so row heights are
|
||||
genuinely shared, no fixed line-clamp/truncation needed. */}
|
||||
<RevealGroup
|
||||
key={products.map((p) => p.id).join(",")}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<RevealItem key={product.id} className="lg:col-span-4">
|
||||
<RevealItem key={product.id} className="lg:col-span-4 grid [grid-template-rows:subgrid] [grid-row:span_6]">
|
||||
<ProductCard
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
@@ -137,7 +142,6 @@ export async function ProductGrid({
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
}
|
||||
className="h-full"
|
||||
/>
|
||||
</RevealItem>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user