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
+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 = {