From 44029cdaad5a842e35f6823dc27e5165f88f149e Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Jul 2026 23:42:33 +0000 Subject: [PATCH] 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. --- README.md | 71 +++++++++++++++++-------- app/cart/components/RelatedProducts.tsx | 21 +++++--- app/components/ProductSpotlight.tsx | 21 +++++--- app/shop/components/ProductGrid.tsx | 25 ++++++--- app/todo-cards/components/Pricing.tsx | 21 +++++--- 5 files changed, 106 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 0549f9b..ebf6648 100644 --- a/README.md +++ b/README.md @@ -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 ``, 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 ``, 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` diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index ea01525..620e5b0 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -167,15 +167,20 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number }) Ausverkauft - ) : discount !== null ? ( - - -{discount}% - ) : ( - anyLowStock && ( - - Nur noch wenige verfügbar - + (discount !== null || anyLowStock) && ( +
+ {discount !== null && ( + + -{discount}% + + )} + {anyLowStock && ( + + Nur noch wenige verfügbar + + )} +
) )} diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index 00196c3..0107b49 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -55,15 +55,20 @@ export async function ProductSpotlight() { Ausverkauft - ) : discount !== null ? ( - - -{discount}% - ) : ( - anyLowStock && ( - - Nur noch wenige verfügbar - + (discount !== null || anyLowStock) && ( +
+ {discount !== null && ( + + -{discount}% + + )} + {anyLowStock && ( + + Nur noch wenige verfügbar + + )} +
) )} diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index e047e9e..048d602 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -57,15 +57,24 @@ export async function ProductGrid() { Ausverkauft - ) : discount !== null ? ( - - -{discount}% - ) : ( - anyLowStock && ( - - Nur noch wenige verfügbar - + (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 + + )} +
) )} diff --git a/app/todo-cards/components/Pricing.tsx b/app/todo-cards/components/Pricing.tsx index ba87005..48d2e43 100644 --- a/app/todo-cards/components/Pricing.tsx +++ b/app/todo-cards/components/Pricing.tsx @@ -48,15 +48,20 @@ export async function Pricing() { Ausverkauft - ) : discount !== null ? ( - - -{discount}% - ) : ( - anyLowStock && ( - - Nur noch wenige verfügbar - + (discount !== null || anyLowStock) && ( +
+ {discount !== null && ( + + -{discount}% + + )} + {anyLowStock && ( + + Nur noch wenige verfügbar + + )} +
) )}