From 39782eeab9c79ad1c475be76484fb77d0e4c067f Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Jul 2026 17:58:10 +0000 Subject: [PATCH] Out-of-stock UI, variant picker on marketing pages, server-side stock check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ` 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 ` setSelectedVariant(e.target.value)} + className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand" + aria-label="Variante auswählen" + > + {variants.map((v) => ( + + ))} + + )} + + + ); } 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 && ( + + )}