From 2df4dc7ea77f0d119a249ee7584ea2322e98c474 Mon Sep 17 00:00:00 2001
From: Marco
Date: Thu, 23 Jul 2026 06:25:52 +0000
Subject: [PATCH] Move low-stock warning from image badge to a text hint, add
it to the cart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The image-overlaid pill made the low-stock message read as clutter on
product photos and had no equivalent in the cart at all. It's now a text
line next to the price (ProductGrid/ProductSpotlight/RelatedProducts/
Pricing) and under the product name in cart line items (variant-specific,
not "any variant low"). The line's height is always reserved, not
conditionally rendered, so cards in the same row stay equal-height
regardless of low-stock state — the exact regression an earlier text-based
version of this hint caused before it was replaced by the image badge.
Co-Authored-By: Claude Sonnet 5
---
README.md | 41 ++++++++++++++-----------
app/cart/components/CartContent.tsx | 11 +++++++
app/cart/components/RelatedProducts.tsx | 34 ++++++++++----------
app/components/ProductSpotlight.tsx | 21 +++++--------
app/shop/components/ProductGrid.tsx | 31 +++++++++----------
app/todo-cards/components/Pricing.tsx | 20 +++++-------
6 files changed, 80 insertions(+), 78 deletions(-)
diff --git a/README.md b/README.md
index ebf6648..960f43a 100644
--- a/README.md
+++ b/README.md
@@ -393,24 +393,29 @@ own distinct badge, the two never combine), and at/below the product's own
`lowStockThreshold`. Neither raw `stock` nor `lowStockThreshold` is
exposed in the public `Product` type, only this derived boolean — the
public API has no reason to leak exact counts. Shown as a "Nur noch wenige
-verfügbar" pill (a new `--color-warning` token in `globals.css`, distinct
-from the brand-colored discount badge so the two never read as the same
-thing) — the same image-overlaid top-left pill Ausverkauft/discount
-already use (`position: absolute`, outside layout flow) on
-`ProductGrid.tsx`/`ProductSpotlight.tsx`/`RelatedProducts.tsx`/todo-cards'
-`Pricing.tsx`, plus a `"(nur noch wenige)"` variant-select suffix in
-`AddToCartButton`/`AddToCartInlineButton`. Stacks with the discount badge
-in a `flex flex-col` column rather than being replaced by it — a
-discounted product that's also low on stock (both are independent
-booleans, not mutually exclusive states) needs to show both, discovered
-live when `todo-karten` had exactly that combination and the low-stock
-pill silently never rendered under the old either/or ternary. Only
-"Ausverkauft" still wins outright, since it replaces both. Deliberately
-**not** a separate
-text line under the add-to-cart button (an earlier version did this) —
-that made the button block's own height vary card-to-card in every grid
-rendering it, breaking equal-height card alignment; the badge doesn't
-participate in layout flow at all, so it can't cause that.
+verfügbar" **text line** (`text-warning`, the same `--color-warning` token
+in `globals.css`, distinct from the brand-colored discount badge) next to
+the price on `ProductGrid.tsx`/`ProductSpotlight.tsx`/
+`RelatedProducts.tsx`/todo-cards' `Pricing.tsx` and — variant-specific, not
+"any variant low" — under the product name in `CartContent.tsx`'s own
+cart line items, plus the existing `"(nur noch wenige)"` variant-select
+suffix in `AddToCartButton`/`AddToCartInlineButton`. The image-overlaid
+top-left pill (`position: absolute`, outside layout flow) now shows only
+Ausverkauft/discount — low-stock moved off the image into text on request,
+since a shopper scanning product photos for "-20%" style badges reads a
+long low-stock sentence pinned to the image as clutter, and a customer
+already in the cart with that product had no low-stock signal there at
+all before this. The earlier version of this text line (removed once,
+see git history) broke equal-height card alignment in `ProductGrid.tsx`/
+`RelatedProducts.tsx` by only rendering when `lowStock` was true, so
+cards with/without the line ended up different heights; this version
+always renders the line's slot (`min-h-[1.05rem]`, empty when not
+low-stock) so every card in a row reserves the same space regardless of
+state — `ProductGrid.tsx` additionally still has its `flex-1` spacer
+pinning the add-to-cart button to the same Y as before, so the reserved
+height is belt-and-suspenders there, but load-bearing in
+`RelatedProducts.tsx`, which has no such spacer. Only "Ausverkauft" still
+wins outright over the discount pill, since it replaces it.
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)` —
a selected variant's `priceOverride` wins over the base `product.price`
diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx
index 58cbb6e..2460a62 100644
--- a/app/cart/components/CartContent.tsx
+++ b/app/cart/components/CartContent.tsx
@@ -184,6 +184,13 @@ export function CartContent({
// when the quantity or remove control is used, same "full
// key" reasoning as cart.ts's own sameLine().
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
+ // The exact variant this line is for, not "any variant low"
+ // like the product-grid cards use — a cart line already has
+ // its variant chosen, so it should only warn when that
+ // specific variant (not some other one) is running low.
+ const lowStock = entry.variant
+ ? (product.variants.find((v) => v.name === entry.variant)?.lowStock ?? false)
+ : product.lowStock;
return (
{i > 0 && }
@@ -204,6 +211,10 @@ export function CartContent({
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
+ {/* Independent stacked rows, not grid siblings — no
+ equal-height pressure from neighboring lines, so a
+ plain conditional line is enough here. */}
+ {lowStock &&
Nur noch wenige verfügbar
}
{product.description}
Einzelpreis
diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx
index 620e5b0..845624f 100644
--- a/app/cart/components/RelatedProducts.tsx
+++ b/app/cart/components/RelatedProducts.tsx
@@ -159,28 +159,20 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
/>
{/* Same top-left pill pattern as ProductGrid.tsx/
ProductSpotlight.tsx — position: absolute, so it never
- affects this card's height the way the old inline
- low-stock text hint under the button used to (that
- variability was exactly what broke equal card heights
- in this grid). */}
+ affects this card's height. Only the discount/Ausverkauft
+ pill lives here now; the low-stock hint moved to a
+ reserved-height text line below (see the min-h paragraph
+ under the price) — plain conditional text here is what
+ broke equal card heights in this grid before. */}
{fullyOutOfStock ? (
Ausverkauft
) : (
- (discount !== null || anyLowStock) && (
-
- {discount !== null && (
-
- -{discount}%
-
- )}
- {anyLowStock && (
-
- Nur noch wenige verfügbar
-
- )}
-
+ discount !== null && (
+
+ -{discount}%
+
)
)}
@@ -198,6 +190,14 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
{formatPrice(product.price)}inkl. {taxRate}% MwSt.
+ {/* Always rendered, text conditional — min-h reserves this
+ line's height in both states so cards in the same row
+ stay equal height regardless of low-stock status; this
+ component has no h-full/flex-1 spacer trick like
+ ProductGrid.tsx to absorb a variable-height line instead. */}
+
+ {anyLowStock ? "Nur noch wenige verfügbar" : null}
+
+ {/* Single product, no grid siblings to stay equal-height with
+ (unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
+ a plain conditional line instead of a reserved-height slot. */}
+ {anyLowStock &&
Nur noch wenige verfügbar
}
{/* items-start at sm: — without it, the default cross-axis
stretch makes "Mehr erfahren" grow to match
AddToCartButton's own height whenever that one gets taller
diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx
index 048d602..17c33b9 100644
--- a/app/shop/components/ProductGrid.tsx
+++ b/app/shop/components/ProductGrid.tsx
@@ -58,23 +58,10 @@ export async function ProductGrid() {
Ausverkauft
) : (
- (discount !== null || anyLowStock) && (
- // Stacked, not either/or — a discounted product running
- // low on stock at the same time (the actual todo-karten
- // case) needs both badges, not just whichever came first
- // in a ternary.
-
- {discount !== null && (
-
- -{discount}%
-
- )}
- {anyLowStock && (
-
- Nur noch wenige verfügbar
-
- )}
-
+ discount !== null && (
+
+ -{discount}%
+
)
)}
@@ -97,6 +84,16 @@ export async function ProductGrid() {
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
+ {/* Always rendered, text conditional — not a conditional
+ block — so this line's height (min-h as a cross-browser
+ safety net for the empty case) is identical whether or
+ not the product is low-stock. See AddToCartButton.tsx's
+ own comment: an earlier text-based low-stock hint here
+ broke equal card heights across the grid, which is why
+ it moved to the image-overlay pill in the first place. */}
+
+ {anyLowStock ? "Nur noch wenige verfügbar" : null}
+
- {discount !== null && (
-
- -{discount}%
-
- )}
- {anyLowStock && (
-
- Nur noch wenige verfügbar
-
- )}
-
+ discount !== null && (
+
+ -{discount}%
+
)
)}
@@ -101,6 +92,9 @@ export async function Pricing() {
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
+ {/* Single product, no grid siblings to stay equal-height with —
+ plain conditional line, same reasoning as ProductSpotlight.tsx. */}
+ {anyLowStock &&
Nur noch wenige verfügbar
}
{/* No "Sichere Zahlung" trust note here (unlike Cart/Checkout) —
this is an add-to-cart step, not the actual payment step, so
a payment-security reassurance is premature here and just