diff --git a/README.md b/README.md index 6ab3ee1..f700ab8 100644 --- a/README.md +++ b/README.md @@ -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 -`` 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 diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index b71616f..4ec0f91 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -112,6 +112,18 @@ export async function POST(request: Request) { variant = product.variants?.find((v) => v.name === line.variant) ?? null; if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 }); } + // Same depth-in-defense reasoning as the price re-check above — the + // storefront already disables "add to cart" for sold-out items, but a + // tampered/stale request could still submit one, so stock is + // re-validated here as the actual source of truth. Falls through + // (buyable) whenever trackInventory is off or backorders are allowed. + const stockSource = line.variant ? product.variants?.find((v) => v.name === line.variant) : product; + if (stockSource?.trackInventory && !stockSource.allowBackorder && (stockSource.stock ?? 0) < line.qty) { + return NextResponse.json( + { ok: false, reason: `"${product.name}"${line.variant ? ` (${line.variant})` : ""} ist nicht mehr in ausreichender Menge verfügbar.` }, + { status: 400 }, + ); + } const imageUrl = typeof product.image === "object" && product.image ? product.image.url : null; items.push({ productId: product.id, diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index 535e973..fe5e72b 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -164,7 +164,7 @@ export function RelatedProducts() { {product.name}

{formatPrice(product.price)}

- + ))} diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index c296f88..5413f85 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -17,6 +17,8 @@ export function AddToCartButton({ label, className, productId = "todo-karten", + outOfStock = false, + variants = [], }: { label: string; className?: string; @@ -24,16 +26,27 @@ export function AddToCartButton({ * ProductSpotlight passes the actual CMS-selected spotlight product's id * explicitly, since that can now be a different product. */ productId?: string; + /** Product-level — only meaningful when `variants` is empty, same split as + * AddToCartInlineButton. */ + outOfStock?: boolean; + /** Optional — same shape/semantics as AddToCartInlineButton's own + * `variants` prop; all three callers already fetch the full product + * server-side, so this is just threaded straight through. */ + variants?: { name: string; priceOverride: number | null; outOfStock: boolean }[]; }) { const [added, setAdded] = useState(false); + const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name); const timeoutRef = useRef | undefined>(undefined); const buttonRef = useRef(null); const { fly } = useCartFly(); useEffect(() => () => clearTimeout(timeoutRef.current), []); + const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock; + function handleClick() { - addToCart(productId); + if (currentlyOutOfStock) return; + addToCart(productId, 1, selectedVariant); if (buttonRef.current) fly(buttonRef.current); setAdded(true); clearTimeout(timeoutRef.current); @@ -55,33 +68,59 @@ export function AddToCartButton({ // a solid bright-green button read as too loud here. `border` (width) is // added here too since `base` has none by default, unlike // AddToCartInlineButton's own base which already carries a plain border. - const stateClasses = added - ? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!" - : ""; + const stateClasses = currentlyOutOfStock + ? "opacity-60 cursor-not-allowed" + : added + ? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!" + : ""; + const displayLabel = currentlyOutOfStock ? "Ausverkauft" : label; return ( - + + ); } diff --git a/app/components/AddToCartInlineButton.tsx b/app/components/AddToCartInlineButton.tsx index f9d584f..ad30928 100644 --- a/app/components/AddToCartInlineButton.tsx +++ b/app/components/AddToCartInlineButton.tsx @@ -20,26 +20,36 @@ export function AddToCartInlineButton({ id, label = "In den Warenkorb", className, + outOfStock = false, variants = [], }: { id: string; label?: string; className?: string; - /** Optional — products.variants (name + optional priceOverride). When - * non-empty, a variant must be picked (defaults to the first one) before - * "add to cart" is enabled — the selected variant's name is snapshotted - * onto the cart line and, later, the order itself. */ - variants?: { name: string; priceOverride: number | null }[]; + /** Product-level — only meaningful when `variants` is empty. A varianted + * product's buyability is entirely per-variant instead (see below). */ + outOfStock?: boolean; + /** Optional — products.variants (name + optional priceOverride + its own + * outOfStock). When non-empty, a variant must be picked (defaults to the + * first *in-stock* one, or just the first if all are out) before "add to + * cart" is enabled — the selected variant's name is snapshotted onto the + * cart line and, later, the order itself. */ + variants?: { name: string; priceOverride: number | null; outOfStock: boolean }[]; }) { const [added, setAdded] = useState(false); - const [selectedVariant, setSelectedVariant] = useState(variants[0]?.name); + const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name); const timeoutRef = useRef | undefined>(undefined); const buttonRef = useRef(null); const { fly } = useCartFly(); useEffect(() => () => clearTimeout(timeoutRef.current), []); + // Whichever is actually being offered right now — the selected variant's + // own flag if there are variants, otherwise the plain product-level one. + const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock; + function handleClick() { + if (currentlyOutOfStock) return; addToCart(id, 1, selectedVariant); if (buttonRef.current) fly(buttonRef.current); setAdded(true); @@ -55,9 +65,11 @@ export function AddToCartInlineButton({ // anymore (it's a trailing `!` now), so two conflicting utilities like // border-border/border-success both being present would silently race on // CSS source order instead of one cleanly winning. - const stateClasses = added - ? "border-success bg-success-subtle" - : "border-border hover:border-brand"; + const stateClasses = currentlyOutOfStock + ? "border-border opacity-60 cursor-not-allowed" + : added + ? "border-success bg-success-subtle" + : "border-border hover:border-brand"; return (
@@ -71,18 +83,25 @@ export function AddToCartInlineButton({ {variants.map((v) => ( ))} )} - diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index d6d6323..9bb4c2f 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -77,7 +77,7 @@ export async function ProductSpotlight() { (matches Tools/Blog above/below), same as AddToCartButton's own default styling/ring-offset, so no override is needed here. */} - + {product.href && ( = {}): RawProduct => ({ taxRatePercent: null, bundleItems: null, variants: null, + trackInventory: false, + stock: null, + allowBackorder: false, ...overrides, }); diff --git a/app/lib/__tests__/cartTotals.test.ts b/app/lib/__tests__/cartTotals.test.ts index 024cfdd..30f931f 100644 --- a/app/lib/__tests__/cartTotals.test.ts +++ b/app/lib/__tests__/cartTotals.test.ts @@ -18,6 +18,7 @@ const product = (overrides: Partial = {}): Product => ({ spotlightText: null, spotlightImage: null, variants: [], + outOfStock: false, ...overrides, }); diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 973725a..e7cd6e0 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -170,7 +170,13 @@ export type Product = { spotlightHeadline: string | null; spotlightText: string | null; spotlightImage: string | null; - variants: { name: string; priceOverride: number | null }[]; + // Plain booleans, not the raw stock/threshold numbers — the public API + // has no reason to leak exact stock counts, callers only ever need + // "can this be bought right now". `outOfStock` on the product itself + // only matters for a product with no variants; a varianted product's + // buyability is entirely per-variant (see each variant's own flag). + outOfStock: boolean; + variants: { name: string; priceOverride: number | null; outOfStock: boolean }[]; }; type PayloadProduct = { @@ -189,9 +195,21 @@ type PayloadProduct = { spotlightHeadline: string | null; spotlightText: string | null; spotlightImage: { url: string } | number | null; - variants: { name: string; priceOverride: number | null }[] | null; + trackInventory: boolean; + stock: number | null; + allowBackorder: boolean; + variants: { name: string; priceOverride: number | null; trackInventory: boolean; stock: number | null; allowBackorder: boolean }[] | null; }; +// A product/variant is only actually unbuyable when it opted into +// inventory tracking AND has zero stock AND backorders aren't allowed — +// the same three-condition check lib/inventory.ts's adjustStock() effectively +// mirrors from the other direction (it only ever touches stock when +// trackInventory is on in the first place). +function isOutOfStock(trackInventory: boolean, stock: number | null, allowBackorder: boolean): boolean { + return trackInventory && !allowBackorder && (stock ?? 0) <= 0; +} + // Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in // one place instead of duplicating the same field mapping, which is // exactly the kind of drift this session's Shipping Settings work was @@ -213,7 +231,12 @@ export function mapPayloadProduct(product: PayloadProduct): Product { spotlightText: product.spotlightText || null, spotlightImage: typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null, - variants: product.variants ?? [], + outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder), + variants: (product.variants ?? []).map((v) => ({ + name: v.name, + priceOverride: v.priceOverride, + outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder), + })), }; } diff --git a/app/lib/productsServer.ts b/app/lib/productsServer.ts index bc9f885..9b27cb2 100644 --- a/app/lib/productsServer.ts +++ b/app/lib/productsServer.ts @@ -11,6 +11,9 @@ export type RawProductVariant = { name: string; sku: string | null; priceOverride: number | null; + trackInventory: boolean; + stock: number | null; + allowBackorder: boolean; }; export type RawProduct = { @@ -23,6 +26,9 @@ export type RawProduct = { taxRatePercent: number | null; bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null; variants: RawProductVariant[] | null; + trackInventory: boolean; + stock: number | null; + allowBackorder: boolean; }; export async function fetchProductsBySlug(): Promise> { diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index 8568437..f357de8 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -28,6 +28,12 @@ export async function ProductGrid() { {products.map((product) => { const discount = discountPercent(product.price, product.compareAtPrice); + // A varianted product only reads as "ausverkauft" overall once + // every one of its variants is — a single sold-out variant just + // shows as such in the picker itself (AddToCartInlineButton), + // not as a blanket badge that would misleadingly suggest the + // whole product is unavailable while other variants still are. + const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock; return ( - {discount !== null && ( - - -{discount}% + {fullyOutOfStock ? ( + + Ausverkauft + ) : ( + discount !== null && ( + + -{discount}% + + ) )}
@@ -82,7 +94,7 @@ export async function ProductGrid() { equal-height lesson). */}
- +
); diff --git a/app/todo-cards/components/Pricing.tsx b/app/todo-cards/components/Pricing.tsx index fbee92e..58c42f9 100644 --- a/app/todo-cards/components/Pricing.tsx +++ b/app/todo-cards/components/Pricing.tsx @@ -79,6 +79,8 @@ export async function Pricing() {
diff --git a/app/todo-cards/components/TodoKartenHero.tsx b/app/todo-cards/components/TodoKartenHero.tsx index 9265451..7632690 100644 --- a/app/todo-cards/components/TodoKartenHero.tsx +++ b/app/todo-cards/components/TodoKartenHero.tsx @@ -107,7 +107,9 @@ export async function TodoKartenHero() {

- + {product && ( + + )}