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:
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 (1–2 handling,
|
||||
// 2–4 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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user