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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -164,7 +164,7 @@ export function RelatedProducts() {
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<AddToCartInlineButton id={product.id} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
button visibly shrink while showing the success state. Stacking
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. */}
|
||||
<span className="relative grid">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
<div className="flex flex-col gap-2">
|
||||
{variants.length > 0 && (
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => 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) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={currentlyOutOfStock}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
button visibly shrink while showing the success state. Stacking
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. Now
|
||||
also reserves space for "Ausverkauft" — the widest of the three
|
||||
wins regardless of which is showing. */}
|
||||
<span className="relative grid">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Ausverkauft
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : label}</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
@@ -71,18 +83,25 @@ export function AddToCartInlineButton({
|
||||
{variants.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={currentlyOutOfStock}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(added ? "font-semibold text-success" : "text-text-primary")
|
||||
(currentlyOutOfStock ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{added ? "Hinzugefügt ✓" : label}
|
||||
{currentlyOutOfStock ? "Ausverkauft" : added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
|
||||
@@ -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. */}
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} />
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
|
||||
@@ -12,6 +12,9 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
|
||||
taxRatePercent: null,
|
||||
bundleItems: null,
|
||||
variants: null,
|
||||
trackInventory: false,
|
||||
stock: null,
|
||||
allowBackorder: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
spotlightText: null,
|
||||
spotlightImage: null,
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+26
-3
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Map<string, RawProduct>> {
|
||||
|
||||
@@ -28,6 +28,12 @@ export async function ProductGrid() {
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
{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 (
|
||||
<RevealItem
|
||||
key={product.id}
|
||||
@@ -39,12 +45,18 @@ export async function ProductGrid() {
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 25vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
{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}%
|
||||
{fullyOutOfStock ? (
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
@@ -82,7 +94,7 @@ export async function ProductGrid() {
|
||||
equal-height lesson). */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<AddToCartInlineButton id={product.id} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
|
||||
@@ -79,6 +79,8 @@ export async function Pricing() {
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
outOfStock={product.outOfStock}
|
||||
variants={product.variants}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -107,7 +107,9 @@ export async function TodoKartenHero() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AddToCartButton label="ToDo-Karten bestellen" />
|
||||
{product && (
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} variants={product.variants} />
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user