feat(shipping): move delivery-time settings to Payload, polish product/cart CTAs

The delivery-time range (handling + transit days) was a hardcoded
HANDLING_DAYS/TRANSIT_DAYS_DE pair in lib/shipping.ts — changing it
needed a code deploy. Now sourced from Payload's new Shipping Settings
collection via getShippingSettings(), threaded down as a prop to the
few Client Components (Cart/Checkout/VersandModal) that can't fetch it
themselves, with the old code constants removed.

Also: the delivery-time note is now shown on every purchase CTA (shop
grid, home spotlight, ToDo-Karten hero + pricing panel), not just one
of them — required next to each buy button per Art. 246a §1 Abs.1
Nr.8 EGBGB, not just somewhere reachable via a link. Checkout's
sidebar was missing the "ab 39€ kostenlos" note Cart already had;
that's fixed too, and both now show the delivery-time range on its own
line instead of crammed onto the shipping-cost line.

Related smaller fixes bundled in since they touch the same files:
price/delivery-time spacing tightened into its own group, the
redundant "Sichere Zahlung" note under Cart's checkout button (already
shown via the trustBadges list right below) replaced with "Sichere
SSL-Verschlüsselung" to match Checkout, and ToDo-Karten's pricing panel
no longer shows a premature payment-security note at the add-to-cart
step.
This commit is contained in:
Marco
2026-07-21 12:44:35 +00:00
parent a6edd7ff61
commit 225a8567a9
13 changed files with 254 additions and 97 deletions
+21 -7
View File
@@ -9,12 +9,13 @@ import { formatPrice, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge } from "../../lib/payload";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
export function CartContent({
trustBadges,
shippingCost,
freeShippingThreshold,
shippingSettings,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
@@ -24,6 +25,12 @@ export function CartContent({
/** Lowest freeShippingThreshold among active ShippingMethods, or null if
* none has one (in which case FreeShippingBanner just doesn't render). */
freeShippingThreshold: number | null;
/** Delivery-time disclosure (Payload's Shipping Settings), fetched by the
* page and threaded down here — this is a Client Component, so it can't
* fetch it itself. Also passed straight through to VersandModal. Named
* "shippingSettings", not "shipping" — that name is already the local
* computed shipping-cost value below. */
shippingSettings: ShippingSettings;
}) {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
@@ -222,6 +229,9 @@ export function CartContent({
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
</div>
<div className="h-px bg-border w-full" />
@@ -247,9 +257,13 @@ export function CartContent({
Zur Kasse gehen
</Link>
<div className="flex gap-[0.625rem] items-center justify-center w-full">
<img alt="" src="/icon-lock.svg" className="w-4 h-[1.125rem]" />
<span className="text-body-sm text-text-muted">Sichere Zahlung</span>
{/* "Sichere SSL-Verschlüsselung", not "Sichere Zahlung" (what
used to be here) — matches /checkout's identical note
under its own buy button; the old wording duplicated the
"Sichere Zahlung" trustBadges entry right below. */}
<div className="flex gap-2 items-center justify-center w-full">
<Image alt="" src="/icon-lock.svg" width={14} height={16} className="w-3.5 h-4" />
<span className="text-body-sm text-text-muted">Sichere SSL-Verschlüsselung</span>
</div>
</div>
@@ -258,7 +272,7 @@ export function CartContent({
<div className="flex flex-col gap-4 items-start w-full">
{trustBadges.map((b) => (
<div key={b.id} className="flex gap-3 items-center w-full">
<img alt="" src={b.icon} className="size-[1.375rem] shrink-0 object-contain" />
<Image alt="" src={b.icon} width={22} height={22} className="size-[1.375rem] shrink-0 object-contain" />
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
</div>
))}
@@ -272,7 +286,7 @@ export function CartContent({
<Reveal className="flex flex-col items-start pb-10 px-[var(--layout-padding-x)] w-full">
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full lg:max-w-[51.875rem]">
<div className="flex flex-col gap-3 items-center justify-center shrink-0">
<img alt="" src="/icon-envelope-hint.png" className="h-[2.8125rem] w-16 object-contain" />
<Image alt="" src="/icon-envelope-hint.png" width={64} height={45} className="h-[2.8125rem] w-16 object-contain" />
<div className="h-[0.1875rem] w-6 bg-brand" />
</div>
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
@@ -294,7 +308,7 @@ export function CartContent({
</Reveal>
)}
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} />
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
</>
);
}
+7 -2
View File
@@ -3,7 +3,7 @@ import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods } from "../lib/payload";
import { getCartTrustBadges, getShippingMethods, getShippingSettings } from "../lib/payload";
// robots: noindex — transactional page (mirrors a specific shopper's cart
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
@@ -18,7 +18,11 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods] = await Promise.all([getCartTrustBadges(), getShippingMethods()]);
const [trustBadges, shippingMethods, shipping] = await Promise.all([
getCartTrustBadges(),
getShippingMethods(),
getShippingSettings(),
]);
// The cart doesn't ask which shipping method the shopper wants yet
// (that's /checkout) — it just estimates using the first active method
@@ -38,6 +42,7 @@ export default async function CartPage() {
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
/>
<RelatedProducts />
<TrustRow />
+15 -3
View File
@@ -10,7 +10,7 @@ import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, generateOrderNumber, type OrderSnapshot } from "../../lib/order";
import type { ShippingMethod, PaymentMethod, TrustBadge } from "../../lib/payload";
import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
function FormField({
label,
@@ -32,10 +32,15 @@ export function CheckoutContent({
shippingMethods,
paymentMethods,
trustBadges,
shippingSettings,
}: {
shippingMethods: ShippingMethod[];
paymentMethods: PaymentMethod[];
trustBadges: TrustBadge[];
/** Delivery-time disclosure (Payload's Shipping Settings) — named
* "shippingSettings", not "shipping", since that name is already the
* local computed shipping-cost value below. */
shippingSettings: ShippingSettings;
}) {
const cart = useCart();
const products = useProducts();
@@ -386,7 +391,14 @@ export function CheckoutContent({
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
</span>
</div>
<p className="text-label text-text-muted">{selectedShipping?.description ?? "innerhalb Deutschlands"}</p>
<p className="text-label text-text-muted">
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
</div>
<div className="h-px bg-border w-full" />
@@ -448,7 +460,7 @@ export function CheckoutContent({
</Reveal>
</div>
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} />
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
</>
);
}
+9 -3
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { CheckoutContent } from "./components/CheckoutContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getShippingMethods, getPaymentMethods, getCartTrustBadges } from "../lib/payload";
import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings } from "../lib/payload";
// robots: noindex — transactional page, same reasoning as /cart.
export const metadata: Metadata = {
@@ -15,16 +15,22 @@ export const metadata: Metadata = {
};
export default async function CheckoutPage() {
const [shippingMethods, paymentMethods, trustBadges] = await Promise.all([
const [shippingMethods, paymentMethods, trustBadges, shippingSettings] = await Promise.all([
getShippingMethods(),
getPaymentMethods(),
getCartTrustBadges(),
getShippingSettings(),
]);
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CheckoutContent shippingMethods={shippingMethods} paymentMethods={paymentMethods} trustBadges={trustBadges} />
<CheckoutContent
shippingMethods={shippingMethods}
paymentMethods={paymentMethods}
trustBadges={trustBadges}
shippingSettings={shippingSettings}
/>
<TrustRow />
</main>
<Footer />
+13 -8
View File
@@ -2,7 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct } from "../lib/payload";
import { getSpotlightProduct, getShippingSettings } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
/**
@@ -22,7 +22,7 @@ import { formatPrice, discountPercent } from "../lib/format";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const product = await getSpotlightProduct();
const [product, shipping] = await Promise.all([getSpotlightProduct(), getShippingSettings()]);
if (!product) return null;
const image = product.spotlightImage || product.image;
@@ -57,12 +57,17 @@ export async function ProductSpotlight() {
<p className="text-body text-text-body">
{product.spotlightText || product.description}
</p>
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
</div>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{/* No className override — the section's bg is bg-bg-base now
+13 -2
View File
@@ -3,14 +3,25 @@
import { useEffect, useRef } from "react";
import { AnimatePresence, motion } from "motion/react";
import { VersandSections } from "../versand/components/VersandSections";
import type { ShippingSettings } from "../lib/payload";
/**
* Quick-reference version of /versand, opened from the cart's order
* summary "Versand" info link — a full page navigation would pull you out
* of checkout, which is exactly what the link is there to avoid. Reuses
* VersandSections so the two never carry different numbers/copy.
* `shipping` is threaded down from CartContent/CheckoutContent's own page
* (a Server Component), not fetched here — this is a Client Component.
*/
export function VersandModal({ open, onClose }: { open: boolean; onClose: () => void }) {
export function VersandModal({
open,
onClose,
shipping,
}: {
open: boolean;
onClose: () => void;
shipping: ShippingSettings;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
@@ -98,7 +109,7 @@ export function VersandModal({ open, onClose }: { open: boolean; onClose: () =>
</div>
<div className="px-8 py-6 pb-8">
<VersandSections />
<VersandSections shipping={shipping} />
</div>
</motion.div>
</motion.div>
+89 -1
View File
@@ -1,3 +1,5 @@
import { formatPrice } from "./format";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
@@ -251,10 +253,44 @@ async function fetchTrustBadgeList(collection: "trust-badges" | "cart-trust-badg
}));
}
// Substitutes literal "{{lieferzeit}}" / "{{kostenfreiab}}" tokens (e.g. in
// a badge's "In {{lieferzeit}} bei dir." or "Ab {{kostenfreiab}}
// Bestellwert innerhalb DE." copy) with the live delivery-time range /
// free-shipping threshold — lets an editor reference these numbers from
// free text without duplicating and hand-maintaining them, which is
// exactly how the "Schneller Versand" and "Versandkostenfrei" badges used
// to drift from the real numbers whenever those changed elsewhere but not
// here too. The threshold comes from the lowest freeShippingThreshold
// among active ShippingMethods (same rule /cart's own banner uses), not
// lib/shipping.ts's separate FREE_SHIPPING_THRESHOLD constant — that
// constant is a third, independent copy of the same fact and not
// necessarily what actually governs checkout.
function resolveShippingTokens(text: string, shipping: ShippingSettings, freeShippingThreshold: number | null): string {
let result = text.replaceAll("{{lieferzeit}}", `${shipping.totalDays.min}${shipping.totalDays.max} Werktagen`);
if (freeShippingThreshold !== null) {
result = result.replaceAll("{{kostenfreiab}}", formatPrice(freeShippingThreshold));
}
return result;
}
// Powers TrustRow.tsx — the horizontal "Schneller Versand /
// Versandkostenfrei / Mit Liebe verpackt" row.
export async function getTrustBadges(): Promise<TrustBadge[]> {
return fetchTrustBadgeList("trust-badges");
const [badges, shipping, shippingMethods] = await Promise.all([
fetchTrustBadgeList("trust-badges"),
getShippingSettings(),
getShippingMethods(),
]);
const thresholds = shippingMethods
.map((m) => m.freeShippingThreshold)
.filter((t): t is number => t !== null);
const freeShippingThreshold = thresholds.length > 0 ? Math.min(...thresholds) : null;
return badges.map((b) => ({
...b,
title: resolveShippingTokens(b.title, shipping, freeShippingThreshold),
description: resolveShippingTokens(b.description, shipping, freeShippingThreshold),
}));
}
// Powers the "Sichere Zahlung / 14 Tage Rückgaberecht / Nachhaltig
@@ -301,6 +337,58 @@ export async function getShippingMethods(): Promise<ShippingMethod[]> {
}));
}
export type ShippingSettings = {
handlingDays: { min: number; max: number };
transitDays: { min: number; max: number };
/** Derived here, not stored in Payload — a third independently-editable
* copy of the same fact is exactly the drift this collection replaces. */
totalDays: { min: number; max: number };
};
type PayloadShippingSettings = {
handlingDaysMin: number;
handlingDaysMax: number;
transitDaysMin: number;
transitDaysMax: number;
};
// Falls back to the site's long-standing real-world numbers (12 handling,
// 24 transit) if Payload has no row yet or the fetch fails — same values
// the old hardcoded HANDLING_DAYS/TRANSIT_DAYS_DE constants used, so
// nothing regresses before this collection gets seeded/edited.
const SHIPPING_SETTINGS_FALLBACK: ShippingSettings = {
handlingDays: { min: 1, max: 2 },
transitDays: { min: 2, max: 4 },
totalDays: { min: 3, max: 6 },
};
export async function getShippingSettings(): Promise<ShippingSettings> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/shipping-settings?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getShippingSettings: Payload returned ${res.status} ${res.statusText}`);
return SHIPPING_SETTINGS_FALLBACK;
}
const data: { docs?: PayloadShippingSettings[] } = await res.json();
const doc = Array.isArray(data.docs) ? data.docs[0] : undefined;
if (!doc) return SHIPPING_SETTINGS_FALLBACK;
const handlingDays = { min: doc.handlingDaysMin, max: doc.handlingDaysMax };
const transitDays = { min: doc.transitDaysMin, max: doc.transitDaysMax };
return {
handlingDays,
transitDays,
totalDays: { min: handlingDays.min + transitDays.min, max: handlingDays.max + transitDays.max },
};
}
export type PaymentMethod = { id: number; title: string; icons: string[] };
type PayloadPaymentMethod = {
+12 -15
View File
@@ -1,17 +1,14 @@
// Single source of truth for shipping numbers/timeframes — consumed by
// both the cart's order summary (CartContent.tsx) and the /versand policy
// page, so the two can never drift apart.
// Single source of truth for shipping numbers — consumed by both the
// cart's order summary (CartContent.tsx) and the /versand policy page, so
// the two can never drift apart.
//
// The delivery-timeframe numbers (handling/transit days, Art. 246a § 1
// Abs. 1 Nr. 8 EGBGB) used to live here too, but now come from Payload's
// Shipping Settings collection instead (see lib/payload.ts's
// getShippingSettings()) — that duplication is exactly why the
// ShippingMethods CMS title once carried a different, drifted day-range
// than this file did. Cost/threshold below have the same drift risk
// against Payload's ShippingMethods price/freeShippingThreshold fields,
// not yet addressed here.
export const SHIPPING_COST = 2.9;
export const FREE_SHIPPING_THRESHOLD = 39;
// Kept as an explicit range (not "so schnell wie möglich") per Art. 246a
// § 1 Abs. 1 Nr. 8 EGBGB — German law requires disclosing a concrete
// delivery timeframe before contract conclusion, and split into
// Bearbeitungszeit/Versanddauer so it's clear whether processing time is
// included in the stated number, not just a single ambiguous figure.
export const HANDLING_DAYS = { min: 1, max: 2 };
export const TRANSIT_DAYS_DE = { min: 2, max: 4 };
export const TOTAL_DAYS_DE = {
min: HANDLING_DAYS.min + TRANSIT_DAYS_DE.min,
max: HANDLING_DAYS.max + TRANSIT_DAYS_DE.max,
};
+15 -9
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts } from "../../lib/payload";
import { getProducts, getShippingSettings } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
@@ -17,7 +17,8 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
const SHOP_GRID_EXCLUDE_IDS = ["notizbuch-klarheit"];
export async function ProductGrid() {
const products = (await getProducts()).filter((p) => !SHOP_GRID_EXCLUDE_IDS.includes(p.id));
const [allProducts, shipping] = await Promise.all([getProducts(), getShippingSettings()]);
const products = allProducts.filter((p) => !SHOP_GRID_EXCLUDE_IDS.includes(p.id));
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
@@ -50,13 +51,18 @@ export async function ProductGrid() {
>
{product.name}
</p>
<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. MwSt.</span>
</p>
<div className="flex flex-col gap-1 items-start">
<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. MwSt.</span>
</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
{product.href && (
<Link
href={product.href}
+18 -17
View File
@@ -1,8 +1,7 @@
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { TOTAL_DAYS_DE } from "../../lib/shipping";
import { getProductBySlug } from "../../lib/payload";
import { getProductBySlug, getShippingSettings } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
const bullets = [
@@ -18,7 +17,7 @@ const bullets = [
// reasoning; the bullet list stays hand-written since it's spec detail,
// not something the Products collection models.
export async function Pricing() {
const product = await getProductBySlug("todo-karten");
const [product, shipping] = await Promise.all([getProductBySlug("todo-karten"), getShippingSettings()]);
if (!product) return null;
const discount = discountPercent(product.price, product.compareAtPrice);
@@ -53,7 +52,7 @@ export async function Pricing() {
<ul className="flex flex-col gap-[0.375rem] items-start">
{bullets.map((b) => (
<li key={b} className="flex gap-2 items-center">
<img alt="" src="/icon-bullet-dot.svg" className="size-1 shrink-0" />
<Image alt="" src="/icon-bullet-dot.svg" width={4} height={4} className="size-1 shrink-0" />
<span className="text-body-sm text-text-primary">{b}</span>
</li>
))}
@@ -61,24 +60,26 @@ export async function Pricing() {
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
</div>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
<p className="text-label text-text-muted">
Lieferzeit: {TOTAL_DAYS_DE.min}{TOTAL_DAYS_DE.max} Werktage innerhalb Deutschlands
</p>
{/* No "Sichere Zahlung" trust note here (unlike Cart/Checkout) —
this is an add-to-cart step, not the actual payment step, so
a payment-security reassurance is premature here and just
duplicates the one shown later at checkout. */}
<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"
/>
<div className="flex gap-[0.375rem] items-center">
<img alt="" src="/icon-lock.svg" className="size-3.5" />
<span className="text-body-sm text-text-primary">Sichere Zahlung</span>
</div>
</div>
</Reveal>
</section>
+21 -14
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug } from "../../lib/payload";
import { getProductBySlug, getShippingSettings } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
const checklist = [
@@ -13,10 +13,12 @@ const checklist = [
// Same "todo-karten" product Pricing.tsx reads further down the page —
// this is just a compact early teaser so the hero's CTA isn't asking for
// a click without saying what it costs; the full price/VAT/shipping-time
// detail still lives only in Pricing.tsx, not duplicated here.
// a click without saying what it costs. Delivery time is repeated here too
// (not just in Pricing.tsx) since this is also a purchase CTA — Art. 246a
// §1 Abs.1 Nr.8 EGBGB's delivery-date disclosure needs to sit next to every
// buy button, not just one of them.
export async function TodoKartenHero() {
const product = await getProductBySlug("todo-karten");
const [product, shipping] = await Promise.all([getProductBySlug("todo-karten"), getShippingSettings()]);
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
return (
@@ -84,21 +86,26 @@ export async function TodoKartenHero() {
<ul className="flex flex-col gap-3 items-start w-full">
{checklist.map((item) => (
<li key={item} className="flex gap-[0.625rem] items-center w-full">
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
<Image alt="" src="/icon-check.svg" width={20} height={20} className="size-5 shrink-0" />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
</ul>
{product && (
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt.</p>
</div>
)}
<div className="flex flex-col gap-1 items-start">
{product && (
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt.</p>
</div>
)}
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
<AddToCartButton label="ToDo-Karten bestellen" />
</div>
+16 -14
View File
@@ -1,12 +1,7 @@
import Link from "next/link";
import {
SHIPPING_COST,
FREE_SHIPPING_THRESHOLD,
HANDLING_DAYS,
TRANSIT_DAYS_DE,
TOTAL_DAYS_DE,
} from "../../lib/shipping";
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { formatPrice } from "../../lib/format";
import type { ShippingSettings } from "../../lib/payload";
// Single source of truth for both the full /versand page and the cart's
// quick-reference VersandModal — same section ids/titles/copy either way,
@@ -51,20 +46,27 @@ function Section({
);
}
export function VersandSections({ withAnchors = false }: { withAnchors?: boolean }) {
export function VersandSections({
withAnchors = false,
shipping,
}: {
withAnchors?: boolean;
shipping: ShippingSettings;
}) {
return (
<div className="flex flex-col gap-10 items-start w-full">
<Section id="lieferzeiten" title="Lieferzeiten" withAnchor={withAnchors}>
<p>
Nach Zahlungseingang bereiten wir deine Bestellung in der Regel innerhalb von{" "}
{HANDLING_DAYS.min}{HANDLING_DAYS.max} Werktagen zum Versand vor. Die Versanddauer
innerhalb Deutschlands beträgt anschließend zusätzlich {TRANSIT_DAYS_DE.min}
{TRANSIT_DAYS_DE.max} Werktage.
{shipping.handlingDays.min}{shipping.handlingDays.max} Werktagen zum Versand vor. Die
Versanddauer innerhalb Deutschlands beträgt anschließend zusätzlich{" "}
{shipping.transitDays.min}{shipping.transitDays.max} Werktage.
</p>
<p>
Damit ist deine Bestellung in der Regel nach {TOTAL_DAYS_DE.min}{TOTAL_DAYS_DE.max}{" "}
Werktagen bei dir, sofern auf der jeweiligen Produktseite nichts anderes angegeben ist.
Als Werktage gelten Montag bis Freitag, ausgenommen gesetzliche Feiertage.
Damit ist deine Bestellung in der Regel nach {shipping.totalDays.min}
{shipping.totalDays.max} Werktagen bei dir, sofern auf der jeweiligen Produktseite nichts
anderes angegeben ist. Als Werktage gelten Montag bis Freitag, ausgenommen gesetzliche
Feiertage.
</p>
</Section>
+5 -2
View File
@@ -4,6 +4,7 @@ import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { VersandSections } from "./components/VersandSections";
import { VersandTOC } from "./components/VersandTOC";
import { getShippingSettings } from "../lib/payload";
export const metadata: Metadata = {
title: "Versand",
@@ -12,7 +13,9 @@ export const metadata: Metadata = {
alternates: { canonical: "/versand" },
};
export default function VersandPage() {
export default async function VersandPage() {
const shipping = await getShippingSettings();
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
@@ -38,7 +41,7 @@ export default function VersandPage() {
<VersandTOC />
</div>
<div className="w-full lg:flex-1 max-w-[45rem]">
<VersandSections withAnchors />
<VersandSections withAnchors shipping={shipping} />
</div>
</div>
</main>