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:
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user