Move trust badges, shipping/payment methods, Werkzeuge cards, product spotlight into CMS

New Payload collections, all editable without a code deploy:
- TrustBadges: the horizontal Schneller-Versand/Versandkostenfrei/Mit-
  Liebe-verpackt row (TrustRow.tsx, now an async server component).
- CartTrustBadges: the Sichere-Zahlung/14-Tage-Rückgaberecht/Nachhaltig-
  verpackt sidebar bullets — shared by /cart (title only) and /checkout
  (title + description), which previously had two different hardcoded
  bullet lists for what's conceptually the same content.
- ShippingMethods: /checkout's Versandart radios. Each method has its own
  optional freeShippingThreshold — omitted means "never free" (Express),
  not "always free". /cart's FreeShippingBanner now targets the lowest
  threshold among active methods instead of a single global constant, and
  hides entirely if no active method has one.
- PaymentMethods: /checkout's Zahlungsart radios, icons as an array
  (Kreditkarte shows 3 logos, PayPal/Überweisung show 1).
- Products: new spotlight/spotlightHeadline/spotlightText/spotlightImage/
  compareAtPrice fields. ProductSpotlight.tsx (homepage) now shows
  whichever product has `spotlight` checked instead of being hardcoded to
  ToDo-Karten, with its own marketing copy separate from the plain
  catalog name/description. AddToCartButton takes an explicit productId
  prop now instead of a hardcoded "todo-karten" constant.
- WerkzeugeCards: the homepage's "Meine Werkzeuge" 3-card grid (Tools.tsx,
  now async). Icons use a uniform box instead of the previous per-card
  hand-tuned width/height/rotation, which only worked for 3 known,
  upside-down-authored SVGs — those were re-exported as pre-flipped PNGs.

lib/payload.ts gained getTrustBadges/getCartTrustBadges/getShippingMethods/
getPaymentMethods/getWerkzeugeCards/getSpotlightProduct, all with the same
graceful-empty-array-on-fetch-failure pattern as the existing functions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-19 23:15:46 +00:00
parent 754966c8ba
commit d472eb546f
10 changed files with 419 additions and 186 deletions
+6 -2
View File
@@ -5,7 +5,6 @@ import { addToCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
const FEEDBACK_MS = 2000;
const PRODUCT_ID = "todo-karten";
/**
* Shared by /todo-cards's Hero + pricing panel and Home's product
@@ -17,9 +16,14 @@ const PRODUCT_ID = "todo-karten";
export function AddToCartButton({
label,
className,
productId = "todo-karten",
}: {
label: string;
className?: string;
/** Defaults to "todo-karten" for /todo-cards' own hardcoded usage — Home's
* ProductSpotlight passes the actual CMS-selected spotlight product's id
* explicitly, since that can now be a different product. */
productId?: string;
}) {
const [added, setAdded] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -29,7 +33,7 @@ export function AddToCartButton({
useEffect(() => () => clearTimeout(timeoutRef.current), []);
function handleClick() {
addToCart(PRODUCT_ID);
addToCart(productId);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
clearTimeout(timeoutRef.current);
+34 -27
View File
@@ -2,36 +2,38 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getProductBySlug } from "../lib/payload";
import { getSpotlightProduct } from "../lib/payload";
import { formatPrice } from "../lib/format";
/**
* Product teaser for ToDo-Karten, placed after the Werkzeuge section (not
* right after the Hero — that already has its own primary CTA, the
* 7-Tage-Challenge, and a second strong purchase CTA competing with it
* there would dilute focus). Werkzeuge already introduces ToDo-Karten as
* a concept with an "Entdecken" link; this is the natural next step —
* a concrete way to buy it, right where interest was just built, rather
* than dropped at the very top before the page has earned any trust.
* Not derived from a Figma frame (page-home never had this section) —
* a deliberate, code-only addition, styled to match the /todo-cards
* pricing panel it's a teaser for. Price/photo come from Payload (same
* "todo-karten" product the shop/cart use) rather than being duplicated
* here as a hardcoded literal, so they can never silently drift apart —
* the marketing headline/copy below stays hand-written, since it's
* deliberately punchier than the plain catalog description.
* Product teaser for whichever product is marked `spotlight` in Payload
* (defaults to none — the section just doesn't render until one is set),
* placed after the Werkzeuge section (not right after the Hero — that
* already has its own primary CTA, the 7-Tage-Challenge, and a second
* strong purchase CTA competing with it there would dilute focus).
* Werkzeuge already introduces the flagship tool as a concept with an
* "Entdecken" link; this is the natural next step — a concrete way to buy
* it, right where interest was just built, rather than dropped at the
* very top before the page has earned any trust. Not derived from a
* Figma frame (page-home never had this section) — a deliberate,
* code-only addition, styled to match /todo-cards' pricing panel.
* Headline/copy/photo are the product's own dedicated spotlight* fields
* (deliberately separate from its plain catalog name/description/image —
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const product = await getProductBySlug("todo-karten");
const product = await getSpotlightProduct();
if (!product) return null;
const image = product.spotlightImage || product.image;
return (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src={product.image}
alt="ToDo-Karten Set"
src={image}
alt={product.name}
fill
sizes="(min-width: 768px) 380px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
@@ -44,12 +46,15 @@ export async function ProductSpotlight() {
className="font-semibold text-h-section text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
ToDo-Karten Kleine Karten. Große Wirkung.
{product.spotlightHeadline || product.name}
</p>
<p className="text-body text-text-body">
50 hochwertige Karten, die dir helfen, deinen Kopf frei zu bekommen und das Wesentliche zu sehen analog, minimalistisch, für jeden Tag.
{product.spotlightText || product.description}
</p>
<div className="flex gap-2 items-center">
{product.compareAtPrice && product.compareAtPrice > product.price && (
<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>
@@ -58,13 +63,15 @@ export async function ProductSpotlight() {
(matches Tools/Blog above/below), same as AddToCartButton's
own default styling/ring-offset, so no override is needed
here. */}
<AddToCartButton label="In den Warenkorb" />
<Link
href="/todo-cards"
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Mehr erfahren
</Link>
<AddToCartButton label="In den Warenkorb" productId={product.id} />
{product.href && (
<Link
href={product.href}
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Mehr erfahren
</Link>
)}
</div>
</div>
</Reveal>
+17 -35
View File
@@ -1,28 +1,18 @@
import Link from "next/link";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import { getWerkzeugeCards } from "../lib/payload";
const tools = [
{
icon: { src: "/icon-rocket.svg", w: "3.375rem", h: "4.122rem", transform: "-scale-y-100" },
title: "Mini-Challenge",
description: "In 7 Tagen zu mehr Klarheit. Kleine Gewohnheiten, die Großes bewirken.",
cta: { label: "Starten", href: "/challenge" },
},
{
icon: { src: "/icon-todo.svg", w: "3rem", h: "3.879rem", transform: "-scale-y-100" },
title: "ToDo-Karten",
description: "Das Werkzeug für Fokus im Alltag. bringe Struktur in deine Aufgaben und gewinne Zeit zurück.",
cta: { label: "Entdecken", href: "/todo-cards" },
},
{
icon: { src: "/icon-newsletter.svg", w: "3rem", h: "2.579rem", transform: "-rotate-4 -scale-y-100" },
title: "Impulse & Tipps",
description: "Wöchentliche Impulse mit konkreten Ideen und erprobten Tipps für weniger Reibung und mehr Leichtigkeit.",
cta: { label: "Anmelden", href: "/newsletter" },
},
];
// Content now lives in Payload (WerkzeugeCards collection). Icons use a
// uniform box here (object-contain) rather than the previous hardcoded
// per-card hand-tuned width/height/rotation — that only made sense for a
// fixed, known set of 3 SVGs authored upside-down for a specific
// hand-drawn look, which doesn't generalize to a real CMS field. The 3
// original icons were re-exported pre-flipped/rotated as PNGs so they
// still display correctly with plain object-contain.
export async function Tools() {
const tools = await getWerkzeugeCards();
if (tools.length === 0) return null;
export function Tools() {
return (
<div id="werkzeuge" className="flex flex-col gap-12 items-start pb-16 pt-8 w-full bg-bg-base">
@@ -44,20 +34,12 @@ export function Tools() {
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
{tools.map((tool) => (
<RevealItem
key={tool.title}
key={tool.id}
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
>
{/* Icon */}
<div className="flex items-center justify-center shrink-0">
<div className={tool.icon.transform}>
<div className="relative" style={{ width: tool.icon.w, height: tool.icon.h }}>
<img
alt=""
src={tool.icon.src}
className="absolute inset-0 w-full h-full"
/>
</div>
</div>
{/* Icon — uniform box, pre-flipped/rotated source asset */}
<div className="flex items-center justify-center shrink-0 size-14">
<img alt="" src={tool.icon} className="max-w-full max-h-full object-contain" />
</div>
{/* Card content — self-stretch + h-full + justify-between so
@@ -85,10 +67,10 @@ export function Tools() {
</p>
</div>
<Link
href={tool.cta.href}
href={tool.ctaHref}
className="font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
>
{tool.cta.label}
{tool.ctaLabel}
</Link>
</div>
</RevealItem>
+11 -18
View File
@@ -1,31 +1,24 @@
import { FREE_SHIPPING_THRESHOLD, TOTAL_DAYS_DE } from "../lib/shipping";
import { formatPrice } from "../lib/format";
import { getTrustBadges } from "../lib/payload";
const items = [
{
icon: "/icon-trust-shipping.png",
title: "Schneller Versand",
desc: `In ${TOTAL_DAYS_DE.min}${TOTAL_DAYS_DE.max} Werktagen bei dir.`,
},
{
icon: "/icon-trust-free-shipping.png",
title: "Versandkostenfrei",
desc: `Ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} Bestellwert innerhalb DE.`,
},
{ icon: "/icon-trust-heart.png", title: "Mit Liebe verpackt", desc: "Für mehr Freude beim Auspacken." },
];
// Content now lives in Payload (TrustBadges collection) instead of being
// hardcoded here, so copy (e.g. the shipping timeframe/threshold numbers)
// can be updated without a code deploy. If the fetch fails or nothing is
// seeded yet, the row just doesn't render rather than showing stale
// hardcoded fallback text that could drift from the real numbers.
export async function TrustRow() {
const items = await getTrustBadges();
if (items.length === 0) return null;
export function TrustRow() {
return (
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-center justify-center py-8 px-[var(--layout-padding-x)]">
{items.map((item, i) => (
<div key={item.title} className="flex items-center gap-6 md:gap-12">
<div key={item.id} className="flex items-center gap-6 md:gap-12">
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
<div className="flex gap-4 items-center">
<img alt="" src={item.icon} className="size-8 shrink-0 object-contain" />
<div className="flex flex-col gap-0.5 items-start">
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.desc}</p>
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
</div>
</div>
</div>