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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+5
-2
@@ -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}
|
||||
/>
|
||||
</Suspense>
|
||||
<RelatedProducts />
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
Reference in New Issue
Block a user