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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user