diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx
index 56df4bd..58cbb6e 100644
--- a/app/cart/components/CartContent.tsx
+++ b/app/cart/components/CartContent.tsx
@@ -22,6 +22,7 @@ export function CartContent({
freeShippingThreshold,
shippingSettings,
defaultTaxRate,
+ showDiscountField,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
@@ -41,6 +42,13 @@ export function CartContent({
* override taxRatePercent themselves — see lib/cartTotals.ts's
* effectiveTaxRate(). */
defaultTaxRate: number;
+ /** Whether Payload currently has at least one active discount code at
+ * all (lib/discountServer.ts's hasActiveDiscountCode()) — no point
+ * showing an open "enter a code" field when nothing could ever validate
+ * against it. Only gates the manual-entry form; a code already applied
+ * (e.g. from an earlier session, or one deactivated after being shared)
+ * still shows its own result row regardless. */
+ showDiscountField: boolean;
}) {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
@@ -275,10 +283,14 @@ export function CartContent({
)}
- {/* Rabattcode — manual input when nothing's applied yet;
- once active, just the result + "Entfernen" (also reached
- via a direct link with a prefilled code, see the useEffect
- above). /checkout mirrors this exact block, sharing state
+ {/* Rabattcode — manual input when nothing's applied yet AND
+ Payload actually has at least one active code right now
+ (showDiscountField — no point offering an open field
+ that could never validate against anything); once
+ active, always shows the result + "Entfernen" regardless
+ of showDiscountField (also reached via a direct link
+ with a prefilled code, see the useEffect above).
+ /checkout mirrors this exact block, sharing state
through lib/discount.ts's localStorage store. */}
{discount ? (
@@ -295,7 +307,7 @@ export function CartContent({
Entfernen
- ) : (
+ ) : showDiscountField ? (
+ ) : (
+ // No manual field to attach an error to (no active codes
+ // exist at all right now) — but a ?code= URL param can
+ // still trigger the auto-apply attempt above regardless
+ // of showDiscountField, so its failure needs somewhere to
+ // show.
+ <>
+ {discountError &&
{discountError}
}
+ {discountLoading &&
Rabattcode wird geprüft…
}
+ >
)}
diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx
index 4cc9caf..ea01525 100644
--- a/app/cart/components/RelatedProducts.tsx
+++ b/app/cart/components/RelatedProducts.tsx
@@ -3,7 +3,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useProducts } from "../../lib/products";
-import { formatPrice } from "../../lib/format";
+import { formatPrice, discountPercent } from "../../lib/format";
+import { effectiveTaxRate } from "../../lib/cartTotals";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
@@ -29,7 +30,7 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
}
-export function RelatedProducts() {
+export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number }) {
const cart = useCart();
const products = useProducts();
// Cart/checkout resolve any product regardless of `active` (see
@@ -112,12 +113,7 @@ export function RelatedProducts() {
- {/* No separate price-disclosure footnote here — the single
- "* inkl. MwSt., zzgl. Versandkosten" note lives directly under
- the cart's own product table instead (CartContent.tsx), close
- enough on the same page view to cover these cards too.
-
- Plain divs, not RevealGroup/RevealItem — this is the one grid on
+ {/* Plain divs, not RevealGroup/RevealItem — this is the one grid on
the site whose items get swapped after the initial mount (see
the swap-in-place effect above). RevealItem has no viewport
trigger of its own; it only ever renders visible because it
@@ -128,7 +124,13 @@ export function RelatedProducts() {
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
- {displayProducts.map((product, i) => (
+ {displayProducts.map((product, i) => {
+ const discount = discountPercent(product.price, product.compareAtPrice);
+ const taxRate = effectiveTaxRate(product, defaultTaxRate);
+ // Same "any vs. every" split as ProductGrid.tsx.
+ const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
+ const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
+ return (
+ {/* Same top-left pill pattern as ProductGrid.tsx/
+ ProductSpotlight.tsx — position: absolute, so it never
+ affects this card's height the way the old inline
+ low-stock text hint under the button used to (that
+ variability was exactly what broke equal card heights
+ in this grid). */}
+ {fullyOutOfStock ? (
+
+ Ausverkauft
+
+ ) : discount !== null ? (
+
+ -{discount}%
+
+ ) : (
+ anyLowStock && (
+
+ Nur noch wenige verfügbar
+
+ )
+ )}
- )}
+ {/* Always a plain street address — Packstation isn't a valid
+ Rechnungsadresse (an invoice needs a real postal address).
+ Packstation is only ever offered below, in the optional
+ "Abweichende Lieferadresse" section's own Lieferart toggle. */}
+ setStreet(e.target.value)}
+ placeholder="Musterstraße 1"
+ autoComplete="street-address"
+ required
+ wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
+ />
setZip(e.target.value)} placeholder="10115" autoComplete="postal-code" required />
setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx
index 3f322fc..0c3c784 100644
--- a/app/components/AddToCartButton.tsx
+++ b/app/components/AddToCartButton.tsx
@@ -18,7 +18,6 @@ export function AddToCartButton({
className,
productId = "todo-karten",
outOfStock = false,
- lowStock = false,
variants = [],
}: {
label: string;
@@ -30,9 +29,6 @@ export function AddToCartButton({
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
- /** Product-level low-stock hint, same "only meaningful without variants"
- * split as outOfStock. */
- lowStock?: 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. */
@@ -47,7 +43,6 @@ export function AddToCartButton({
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
- const currentlyLowStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.lowStock ?? false) : lowStock;
function handleClick() {
if (currentlyOutOfStock) return;
@@ -81,7 +76,17 @@ export function AddToCartButton({
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : label;
return (
-
+ // Low stock is deliberately NOT surfaced here as its own text line
+ // (it used to be) — that made this block's height vary card-to-card
+ // in every grid that renders this component, breaking equal-height
+ // card alignment (ProductSpotlight's CTA row, RelatedProducts' grid).
+ // The image-overlaid pill badge (ProductGrid.tsx/ProductSpotlight.tsx/
+ // RelatedProducts.tsx, position: absolute, doesn't participate in
+ // layout flow) is the one place this now shows, same as
+ // Ausverkauft/discount already do. The variant-select suffix below is
+ // unaffected — a native