Move low-stock warning from image badge to a text hint, add it to the cart

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 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 06:25:52 +00:00
parent d72102bdf0
commit 2df4dc7ea7
6 changed files with 80 additions and 78 deletions
+23 -18
View File
@@ -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`
+11
View File
@@ -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 (
<div key={lineKey} className="w-full">
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
@@ -204,6 +211,10 @@ export function CartContent({
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
{/* Independent stacked rows, not grid siblings — no
equal-height pressure from neighboring lines, so a
plain conditional line is enough here. */}
{lowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
<div className="flex flex-col gap-0.5 items-start">
<p className="text-label text-text-muted">Einzelpreis</p>
+17 -17
View File
@@ -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 ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
(discount !== null || anyLowStock) && (
<div className="absolute top-3 left-3 flex flex-col items-start gap-1">
{discount !== null && (
<span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)}
{anyLowStock && (
<span className="rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)}
</div>
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
@@ -198,6 +190,14 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
</p>
{/* 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. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
</div>
</div>
+8 -13
View File
@@ -56,19 +56,10 @@ export async function ProductSpotlight() {
Ausverkauft
</span>
) : (
(discount !== null || anyLowStock) && (
<div className="absolute top-3 left-3 flex flex-col items-start gap-1">
{discount !== null && (
<span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)}
{anyLowStock && (
<span className="rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)}
</div>
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
@@ -99,6 +90,10 @@ export async function ProductSpotlight() {
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
{/* 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 && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
{/* 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
+14 -17
View File
@@ -58,23 +58,10 @@ export async function ProductGrid() {
Ausverkauft
</span>
) : (
(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.
<div className="absolute top-3 left-3 flex flex-col items-start gap-1">
{discount !== null && (
<span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)}
{anyLowStock && (
<span className="rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)}
</div>
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
@@ -97,6 +84,16 @@ export async function ProductGrid() {
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
{/* 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. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
{product.href && (
<Link
href={product.href}
+7 -13
View File
@@ -49,19 +49,10 @@ export async function Pricing() {
Ausverkauft
</span>
) : (
(discount !== null || anyLowStock) && (
<div className="absolute top-3 left-3 flex flex-col items-start gap-1">
{discount !== null && (
<span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)}
{anyLowStock && (
<span className="rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)}
</div>
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
@@ -101,6 +92,9 @@ export async function Pricing() {
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
{/* Single product, no grid siblings to stay equal-height with
plain conditional line, same reasoning as ProductSpotlight.tsx. */}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
{/* 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