Out-of-stock UI, variant picker on marketing pages, server-side stock check
- ProductGrid/AddToCartInlineButton/AddToCartButton now show "Ausverkauft" and disable add-to-cart per variant (or product-level with no variants), derived from trackInventory/stock/allowBackorder via isOutOfStock(). - AddToCartButton (todo-cards Hero+Pricing, homepage spotlight) gains the same variant <select> AddToCartInlineButton already had — all three call sites already fetch full product data server-side. - /api/checkout re-validates stock server-side (depth-in-defense, not just the disabled button), rejecting when trackInventory is on, allowBackorder is off, and requested qty exceeds stock. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
This commit is contained in:
@@ -283,15 +283,26 @@ sides (the common no-variants case) still matches by simple equality —
|
||||
every pre-existing call site that never passes a variant keeps working
|
||||
unchanged.
|
||||
|
||||
**Where a variant gets picked**: `AddToCartInlineButton` renders a
|
||||
`<select>` above the button when its `variants` prop is non-empty
|
||||
(`ProductGrid.tsx`/`RelatedProducts.tsx` pass `product.variants` straight
|
||||
through from `getProducts()`'s mapped `Product` type), defaulting to the
|
||||
first variant. `AddToCartButton` (the marketing-page-specific one on
|
||||
`/todo-cards` and the homepage spotlight) does **not** have a variant
|
||||
picker — those reference one hardcoded product id directly with no
|
||||
product data in scope, so if that specific product ever gets variants,
|
||||
this button would need its own follow-up work.
|
||||
**Where a variant gets picked**: both `AddToCartInlineButton`
|
||||
(`/shop`, `/cart`'s related-products grid) and `AddToCartButton` (the
|
||||
marketing-page-specific one on `/todo-cards`' Hero + Pricing panel and the
|
||||
homepage spotlight) render a `<select>` above the button when their
|
||||
`variants` prop is non-empty, defaulting to the first *in-stock* variant.
|
||||
All five call sites already fetch the full product server-side
|
||||
(`getProducts()`/`getProductBySlug()`/`getSpotlightProduct()`), so
|
||||
`product.variants` and `product.outOfStock` are simply passed straight
|
||||
through — no separate data-fetch needed for `AddToCartButton`'s two pages.
|
||||
|
||||
**Out-of-stock UI**: `app/lib/payload.ts`'s `isOutOfStock()` derives
|
||||
`Product.outOfStock` (and each `variants[].outOfStock`) from
|
||||
`trackInventory`/`stock`/`allowBackorder` — true only when inventory is
|
||||
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.
|
||||
|
||||
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)` —
|
||||
a selected variant's `priceOverride` wins over the base `product.price`
|
||||
@@ -304,7 +315,11 @@ instead of reading `product.price` directly. The checkout route
|
||||
too — same "never trust the client" reasoning as price re-derivation
|
||||
generally: a `line.variant` naming something that doesn't exist on that
|
||||
product (removed, or a tampered request) fails the whole checkout rather
|
||||
than silently falling back to the base price.
|
||||
than silently falling back to the base price. It also re-checks stock at
|
||||
that same point — depth-in-defense, not just the disabled button UI above
|
||||
— rejecting the order when the resolved product/variant has
|
||||
`trackInventory` on, `allowBackorder` off, and less `stock` than the
|
||||
requested quantity.
|
||||
|
||||
**Snapshotting**: `orders.items[].variantName` captures which variant was
|
||||
picked at order time (same "snapshot, not a live relationship" reasoning
|
||||
@@ -854,6 +869,18 @@ sees.
|
||||
monitor in the existing "Content & API" group (`~/dev/README.md`'s
|
||||
documented `sqlite3`-insert method, Kuma 1.x has no REST API for this).
|
||||
|
||||
`/shop` itself also has its own Kuma HTTP monitor ("einfach-produktiv Shop
|
||||
(Produkte, Varianten, Lagerbestand)", same "Content & API" group) — added
|
||||
once the shop grid started doing real work at render time (`fullyOutOfStock`
|
||||
across a product's variants, `effectivePrice()`), not just listing static
|
||||
content; `/api/health` alone only proves Payload is reachable, not that this
|
||||
specific page still renders. The Payload jobs queue's own failure monitor
|
||||
(`/api/health/jobs`, `hasError: true` in the last 24h) already covers all
|
||||
five scheduled jobs generically by task-agnostic query — the four added this
|
||||
session (low-stock digest, stale-unverified-accounts report, weekly revenue
|
||||
report, expired-discount-code cleanup) needed no monitor changes of their
|
||||
own; see the Payload README's "Jobs Queue" section.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
|
||||
|
||||
Reference in New Issue
Block a user