Move low-stock hint to badge, right-align VAT breakdown, gate cart discount field, split billing/shipping delivery method

- 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 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 23:25:18 +00:00
parent dc6b61324f
commit a50524832e
13 changed files with 175 additions and 141 deletions
+27 -5
View File
@@ -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({
</div>
)}
{/* 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 ? (
<div className="flex flex-col gap-2 w-full">
@@ -295,7 +307,7 @@ export function CartContent({
Entfernen
</button>
</div>
) : (
) : showDiscountField ? (
<form
onSubmit={(e) => {
e.preventDefault();
@@ -324,6 +336,16 @@ export function CartContent({
{discountError && <p className="text-label text-red-600">{discountError}</p>}
{discountLoading && <p className="text-label text-text-muted">Rabattcode wird geprüft</p>}
</form>
) : (
// 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 && <p className="text-label text-red-600 w-full">{discountError}</p>}
{discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft</p>}
</>
)}
<div className="flex flex-col gap-0.5 w-full">
+42 -12
View File
@@ -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() {
</p>
</Reveal>
{/* 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. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{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 (
<div
key={product.id}
className={
@@ -155,6 +157,27 @@ export function RelatedProducts() {
sizes="(min-width: 768px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* 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 ? (
<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>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<p
@@ -163,11 +186,18 @@ export function RelatedProducts() {
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
</div>
</div>
))}
);
})}
</div>
</section>
);