From a50524832eac785aaecbf8a4fc3c43cdc0d264ff Mon Sep 17 00:00:00 2001
From: Marco
Date: Wed, 22 Jul 2026 23:25:18 +0000
Subject: [PATCH] Move low-stock hint to badge, right-align VAT breakdown, gate
cart discount field, split billing/shipping delivery method
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Removed the inline "Nur noch wenige verfügbar" text hint from
AddToCartButton/AddToCartInlineButton (was making card heights vary in
every grid that renders them — RelatedProducts, ProductSpotlight's CTA
row) — now only shown via the same image-overlaid pill badge
Ausverkauft/discount already use (position: absolute, doesn't affect
layout). Added that badge to RelatedProducts.tsx and todo-cards'
Pricing.tsx, which didn't have it before.
- RelatedProducts cards now also show "inkl. X% MwSt." (was missing
entirely)
- VatBreakdown rows are now flex rows with a spacer instead of plain
text, so every € amount right-aligns to the same edge regardless of
how many digits the rate itself has (was visibly staggered with mixed
7%/19% rates)
- Cart's manual discount-code field only renders when Payload actually
has at least one active code right now (lib/discountServer.ts's new
hasActiveDiscountCode()) — no point showing an open field that could
never validate. An already-applied code (e.g. from an older session)
still always shows its own result row regardless.
- Checkout's "1. Rechnungsadresse" no longer offers a Packstation option
— a Packstation isn't a valid billing address for an invoice. Only a
plain street address now; Packstation is only offered on the separate,
optional "Abweichende Lieferadresse" section, which already had its own
address/Packstation toggle.
Co-Authored-By: Claude Sonnet 5
---
app/cart/components/CartContent.tsx | 32 +++++-
app/cart/components/RelatedProducts.tsx | 54 +++++++--
app/cart/page.tsx | 7 +-
app/checkout/components/CheckoutContent.tsx | 109 ++++---------------
app/components/AddToCartButton.tsx | 20 ++--
app/components/AddToCartInlineButton.tsx | 15 +--
app/components/ProductSpotlight.tsx | 2 +-
app/components/VatBreakdown.tsx | 26 +++--
app/lib/checkoutDraft.ts | 6 +-
app/lib/discountServer.ts | 25 +++++
app/shop/components/ProductGrid.tsx | 2 +-
app/todo-cards/components/Pricing.tsx | 16 ++-
app/todo-cards/components/TodoKartenHero.tsx | 2 +-
13 files changed, 175 insertions(+), 141 deletions(-)
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
+
+ )
+ )}
- ))}
+ );
+ })}
);
diff --git a/app/cart/page.tsx b/app/cart/page.tsx
index 25b54c5..4378af6 100644
--- a/app/cart/page.tsx
+++ b/app/cart/page.tsx
@@ -5,6 +5,7 @@ import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
+import { hasActiveDiscountCode } from "../lib/discountServer";
// robots: noindex — transactional page (mirrors a specific shopper's cart
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
@@ -19,11 +20,12 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
- const [trustBadges, shippingMethods, shipping, defaultTaxRate] = await Promise.all([
+ const [trustBadges, shippingMethods, shipping, defaultTaxRate, showDiscountField] = await Promise.all([
getCartTrustBadges(),
getShippingMethods(),
getShippingSettings(),
getDefaultTaxRatePercent(),
+ hasActiveDiscountCode(),
]);
// The cart doesn't ask which shipping method the shopper wants yet
@@ -53,9 +55,10 @@ export default async function CartPage() {
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
defaultTaxRate={defaultTaxRate}
+ showDiscountField={showDiscountField}
/>
-
+
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index 341bc84..eea6baa 100644
--- a/app/checkout/components/CheckoutContent.tsx
+++ b/app/checkout/components/CheckoutContent.tsx
@@ -70,7 +70,6 @@ export function CheckoutContent({
const [shippingMethodId, setShippingMethodId] = useState(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState(paymentMethods[0]?.id ?? null);
const [versandOpen, setVersandOpen] = useState(false);
- const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(savedProfile?.deliveryMethod ?? "address");
const [purchaseError, setPurchaseError] = useState(null);
const [purchasing, setPurchasing] = useState(false);
const [showLogin, setShowLogin] = useState(false);
@@ -90,9 +89,10 @@ export function CheckoutContent({
const [firstName, setFirstName] = useState(savedProfile?.firstName ?? "");
const [lastName, setLastName] = useState(savedProfile?.lastName ?? "");
const [email, setEmail] = useState(savedProfile?.email ?? customerEmail ?? "");
+ // Always a plain street address — a Packstation isn't a valid Rechnungs-
+ // adresse (an invoice needs a real postal address). Packstation is only
+ // ever offered on the separate, optional shipping-address override below.
const [street, setStreet] = useState(savedProfile?.street ?? "");
- const [packstationNumber, setPackstationNumber] = useState(savedProfile?.packstationNumber ?? "");
- const [postNumber, setPostNumber] = useState(savedProfile?.postNumber ?? "");
const [zip, setZip] = useState(savedProfile?.zip ?? "");
const [city, setCity] = useState(savedProfile?.city ?? "");
const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland");
@@ -130,10 +130,7 @@ export function CheckoutContent({
if (draft.firstName) setFirstName(draft.firstName);
if (draft.lastName) setLastName(draft.lastName);
if (draft.email) setEmail(draft.email);
- if (draft.deliveryMethod) setDeliveryMethod(draft.deliveryMethod);
if (draft.street) setStreet(draft.street);
- if (draft.packstationNumber) setPackstationNumber(draft.packstationNumber);
- if (draft.postNumber) setPostNumber(draft.postNumber);
if (draft.zip) setZip(draft.zip);
if (draft.city) setCity(draft.city);
if (draft.country) setCountry(draft.country);
@@ -161,10 +158,7 @@ export function CheckoutContent({
firstName,
lastName,
email,
- deliveryMethod,
street,
- packstationNumber,
- postNumber,
zip,
city,
country,
@@ -187,10 +181,7 @@ export function CheckoutContent({
firstName,
lastName,
email,
- deliveryMethod,
street,
- packstationNumber,
- postNumber,
zip,
city,
country,
@@ -317,10 +308,10 @@ export function CheckoutContent({
// one address-card field that stays uncontrolled/unpersisted (see
// lib/checkoutDraft.ts's own comment on why).
password: customerEmail ? undefined : String(form.get("password") ?? ""),
- deliveryMethod,
+ // Rechnungsadresse is always a plain street address — see the
+ // street state's own comment on why Packstation isn't offered here.
+ deliveryMethod: "address" as const,
street: street || undefined,
- packstationNumber: packstationNumber || undefined,
- postNumber: postNumber || undefined,
zip,
city,
country,
@@ -547,79 +538,21 @@ export function CheckoutContent({
)}
- {/* Segmented control, same sm:w-[calc(50%-0.5rem)] half-row
- width as the field(s) below it — Lieferadresse keeps
- Straße und Hausnummer, Packstation swaps it out for
- Packstationnummer + Postnummer (DHL's two Packstation
- identifiers; there's no house number to give). */}
-
- )}
+ {/* 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