Stack low-stock badge with discount badge instead of hiding it, update READMEs

Ausverkauft/discount/low-stock badges used a single either/or slot, so a
product with an active discount silently never showed its low-stock pill
(caught live: todo-karten had both at once). Badges now stack in a flex
column across ProductGrid/ProductSpotlight/RelatedProducts/Pricing, with
Ausverkauft still winning outright. READMEs updated for this and the
recent discount-field gating, shipping-address, and money-rounding changes.
This commit is contained in:
Marco
2026-07-22 23:42:33 +00:00
parent b2bffd13a3
commit 44029cdaad
5 changed files with 106 additions and 53 deletions
+50 -21
View File
@@ -196,15 +196,21 @@ no second input) — real server-side validation, not just a client-side
check against Payload's public API, unlike most content on this site.
- **Manual input field on `/cart`** (`CartContent.tsx`) — a text field +
"Anwenden" button, shown whenever no code is currently applied; once
applied, the field is replaced by a read-only result + "Entfernen" link
(reverted an earlier no-manual-input decision). `?code=SAVE10` on the
`/cart` URL still auto-applies once on arrival (a `useEffect` reading
`useSearchParams()` — requires `/cart`'s `page.tsx` to wrap `CartContent`
in `<Suspense>`, a Next.js requirement for any `useSearchParams()`
consumer), so a marketing link still works without the shopper typing
anything. A code that arrives via the URL but turns out invalid/expired
shows the same inline error the manual field uses.
"Anwenden" button, shown whenever no code is currently applied *and*
Payload actually has at least one active code right now
(`hasActiveDiscountCode()` in `discountServer.ts`, `where[active][equals]=true`,
ISR-cached 60s — no point offering an open field that could never
validate against anything). Once applied, the field is replaced by a
read-only result + "Entfernen" link, shown regardless of that check (an
already-applied code, e.g. from an older session, still needs somewhere
to display even if no *other* code happens to be active right now).
`?code=SAVE10` on the `/cart` URL still auto-applies once on arrival (a
`useEffect` reading `useSearchParams()` — requires `/cart`'s `page.tsx`
to wrap `CartContent` in `<Suspense>`, a Next.js requirement for any
`useSearchParams()` consumer) regardless of `hasActiveDiscountCode()`
too, so a marketing link still works without the shopper typing
anything; if that auto-apply fails, the error shows even without the
manual field present.
- **`app/lib/discountServer.ts`** (server-only, imported exclusively by the
two route handlers below — never by a `"use client"` component, same
reasoning as Live Preview's `next/headers` lesson above) talks to
@@ -251,7 +257,13 @@ check against Payload's public API, unlike most content on this site.
re-validates+redeems a discount code exactly once, registers a new
account inline if nobody's logged in yet ("Konto Pflicht" — see below),
and only then creates the order in Payload's `orders` collection via
`app/lib/orderServer.ts`. `app/lib/order.ts`'s `OrderSnapshot` is still
`app/lib/orderServer.ts`. `subtotal`/`discountAmount`/`total` are each
rounded to 2 decimals (`roundMoney()`) right before being persisted —
plain float arithmetic on a summed/percent-discounted cart drifts into
values like `84.30000000000001`, invisible wherever a display already
ran the number through `formatPrice()`'s `toFixed(2)`, but stored as-is
otherwise and visible raw in the Payload admin's plain number field for
`total`. `app/lib/order.ts`'s `OrderSnapshot` is still
written to `sessionStorage` for `/bestellbestaetigung` to read once, but
its `orderNumber`/`orderDateIso` now come back from that Payload create
call, not generated client-side.
@@ -316,9 +328,13 @@ excluded — stays a plain uncontrolled, unpersisted input.
### Optional deviating shipping address
A checkbox in "1. Rechnungsadresse" ("Abweichende Lieferadresse
verwenden") reveals a second address section (own name + delivery method +
street/Packstation/PLZ/Ort/Land) — when used, the order's original address
"1. Rechnungsadresse" always collects a plain street address now — no
Lieferart (Lieferadresse/Packstation) toggle there anymore, since a
Packstation isn't a valid billing address for an invoice. A separate
checkbox ("Abweichende Lieferadresse verwenden") reveals a second address
section with its *own* delivery-method toggle (own name + delivery method +
street/Packstation/PLZ/Ort/Land) — that's the only place Packstation
delivery is offered at all. When used, the order's original address
fields stay the **billing** address (used for the invoice's "An" block
regardless), and the `shipping*`-prefixed fields (`hasDifferentShippingAddress`,
`shippingFirstName`/`shippingLastName`/`shippingDeliveryMethod`/
@@ -363,10 +379,11 @@ through — no separate data-fetch needed for `AddToCartButton`'s two pages.
tracked, backorders aren't allowed, and `stock <= 0`. Both add-to-cart
buttons disable themselves and show "Ausverkauft" for whichever variant is
currently selected (or the plain product, when there are no variants);
`ProductGrid.tsx` additionally shows an "Ausverkauft" badge (replacing the
discount badge, never both) once *every* variant of a product is out —
one sold-out variant among several just reads as such in the picker
itself, not as a misleading blanket badge.
`ProductGrid.tsx` additionally shows an "Ausverkauft" badge (replacing any
discount/low-stock badge — a sold-out product has nothing else useful to
show) once *every* variant of a product is out — one sold-out variant
among several just reads as such in the picker itself, not as a
misleading blanket badge.
**Low-stock warning**: `app/lib/payload.ts`'s `isLowStock()` derives
`Product.lowStock` (and each `variants[].lowStock`) from `trackInventory`/
@@ -378,10 +395,22 @@ 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) on `ProductGrid.tsx`/`ProductSpotlight.tsx`, a `"(nur noch wenige)"`
variant-select suffix, and a text hint under `AddToCartButton`/
`AddToCartInlineButton` — same component/prop shape as `outOfStock`
throughout.
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.
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)`
a selected variant's `priceOverride` wins over the base `product.price`
+13 -8
View File
@@ -167,15 +167,20 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
<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 ? (
<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>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</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>
)
)}
</div>
+13 -8
View File
@@ -55,15 +55,20 @@ export async function ProductSpotlight() {
<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 ? (
<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>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</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>
)
)}
</div>
+17 -8
View File
@@ -57,15 +57,24 @@ export async function ProductGrid() {
<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 ? (
<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>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</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>
)
)}
</div>
+13 -8
View File
@@ -48,15 +48,20 @@ export async function Pricing() {
<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 ? (
<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>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</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>
)
)}
</div>