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
+27 -13
View File
@@ -6,12 +6,25 @@ import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge } from "../../lib/payload";
export function CartContent() {
export function CartContent({
trustBadges,
shippingCost,
freeShippingThreshold,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
* estimate, since the cart doesn't ask which method the shopper wants
* yet (that's /checkout). */
shippingCost: number;
/** Lowest freeShippingThreshold among active ShippingMethods, or null if
* none has one (in which case FreeShippingBanner just doesn't render). */
freeShippingThreshold: number | null;
}) {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
const products = useProducts();
@@ -27,7 +40,10 @@ export function CartContent() {
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
const shipping = items.length === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST;
const shipping =
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
? 0
: shippingCost;
const total = subtotal + shipping;
return (
@@ -70,7 +86,7 @@ export function CartContent() {
) : (
<>
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
<FreeShippingBanner subtotal={subtotal} />
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
{/* Cart card — lg:-only split from the sidebar (same "wide content
@@ -179,8 +195,8 @@ export function CartContent() {
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0
? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands`
{shipping === 0 && freeShippingThreshold !== null
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
</div>
@@ -214,15 +230,13 @@ export function CartContent() {
</div>
</div>
{/* Title only — /checkout renders the same CartTrustBadges
docs with description too, see CheckoutContent.tsx. */}
<div className="flex flex-col gap-4 items-start w-full">
{[
{ icon: "/icon-trust-leaf.png", text: "Nachhaltig produziert in Deutschland" },
{ icon: "/icon-trust-materials.png", text: "Hochwertige Materialien" },
{ icon: "/icon-trust-return.png", text: "14 Tage Rückgaberecht" },
].map((b) => (
<div key={b.text} className="flex gap-3 items-center 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" />
<span className="flex-1 text-body-sm text-text-primary">{b.text}</span>
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
</div>
))}
</div>
+17 -5
View File
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { formatPrice } from "../../lib/format";
const SUCCESS_VISIBLE_MS = 2500;
@@ -14,9 +13,22 @@ type Phase = "progress" | "success" | "hidden";
* /shop, per the deliberately narrower scope agreed with the user (a cart
* item can leave/re-enter the threshold as quantities change, so this
* needs to react to that, not just fire once).
*
* `threshold` is the lowest freeShippingThreshold among /checkout's active
* ShippingMethods (computed by the caller) — not every method necessarily
* has one (Express never does, it always costs extra), so this shows the
* easiest one to reach rather than an arbitrary/average value. `null`
* means no active method has a threshold at all, so there's nothing to
* nudge toward — the banner just doesn't render.
*/
export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
const reached = subtotal >= FREE_SHIPPING_THRESHOLD;
export function FreeShippingBanner({ subtotal, threshold }: { subtotal: number; threshold: number | null }) {
if (threshold === null) return null;
return <FreeShippingBannerInner subtotal={subtotal} threshold={threshold} />;
}
function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; threshold: number }) {
const reached = subtotal >= threshold;
const [phase, setPhase] = useState<Phase>(reached ? "success" : "progress");
// React's documented pattern for "adjust state when a prop changes" —
@@ -50,8 +62,8 @@ export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
return () => clearTimeout(t);
}, [phase]);
const remaining = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal);
const progressPct = Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100);
const remaining = Math.max(0, threshold - subtotal);
const progressPct = Math.min(100, (subtotal / threshold) * 100);
return (
// AnimatePresence + exit, not a plain `if (phase === "hidden") return
+20 -2
View File
@@ -3,6 +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";
// robots: noindex — transactional page (mirrors a specific shopper's cart
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
@@ -16,11 +17,28 @@ export const metadata: Metadata = {
},
};
export default function CartPage() {
export default async function CartPage() {
const [trustBadges, shippingMethods] = await Promise.all([getCartTrustBadges(), getShippingMethods()]);
// The cart doesn't ask which shipping method the shopper wants yet
// (that's /checkout) — it just estimates using the first active method
// (Standard, by sortOrder) for the sidebar's "Versand" line, and shows
// the FreeShippingBanner toward whichever active method's threshold is
// lowest/easiest to reach (Express has none — it never goes free).
const defaultShipping = shippingMethods[0] ?? null;
const thresholds = shippingMethods
.map((m) => m.freeShippingThreshold)
.filter((t): t is number => t !== null);
const freeShippingThreshold = thresholds.length > 0 ? Math.min(...thresholds) : null;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CartContent />
<CartContent
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
/>
<RelatedProducts />
<TrustRow />
</main>
+62 -82
View File
@@ -6,11 +6,9 @@ import Image from "next/image";
import { useCart } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
const EXPRESS_SHIPPING_COST = 4.9;
import type { ShippingMethod, PaymentMethod, TrustBadge } from "../../lib/payload";
const steps = [
{ label: "Warenkorb", state: "done" as const },
@@ -59,11 +57,19 @@ function FormField({
);
}
export function CheckoutContent() {
export function CheckoutContent({
shippingMethods,
paymentMethods,
trustBadges,
}: {
shippingMethods: ShippingMethod[];
paymentMethods: PaymentMethod[];
trustBadges: TrustBadge[];
}) {
const cart = useCart();
const products = useProducts();
const [shippingMethod, setShippingMethod] = useState<"standard" | "express">("standard");
const [paymentMethod, setPaymentMethod] = useState<"card" | "paypal" | "bank">("card");
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentMethods[0]?.id ?? null);
const [versandOpen, setVersandOpen] = useState(false);
const productsLoading = products.length === 0 && cart.length > 0;
@@ -72,12 +78,12 @@ export function CheckoutContent() {
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
const freeShipping = subtotal >= FREE_SHIPPING_THRESHOLD;
const shipping = items.length === 0 || freeShipping
? 0
: shippingMethod === "express"
? EXPRESS_SHIPPING_COST
: SHIPPING_COST;
const selectedShipping = shippingMethods.find((m) => m.id === shippingMethodId) ?? null;
const freeShipping =
selectedShipping?.freeShippingThreshold !== null &&
selectedShipping?.freeShippingThreshold !== undefined &&
subtotal >= selectedShipping.freeShippingThreshold;
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
const total = subtotal + shipping;
if (!productsLoading && items.length === 0) {
@@ -209,29 +215,27 @@ export function CheckoutContent() {
>
2. Versandart
</p>
{(
[
{ id: "standard", label: "Standardversand (24 Werktage)", price: SHIPPING_COST },
{ id: "express", label: "Expressversand (12 Werktage)", price: EXPRESS_SHIPPING_COST },
] as const
).map((option) => (
<label key={option.id} className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="shipping"
checked={shippingMethod === option.id}
onChange={() => setShippingMethod(option.id)}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 flex flex-col gap-0.5">
<span className="text-body-sm text-text-primary">{option.label}</span>
<span className="text-label text-text-muted">innerhalb Deutschlands</span>
</span>
<span className="text-body-sm text-text-primary whitespace-nowrap">
{freeShipping ? "Kostenlos" : formatPrice(option.price)}
</span>
</label>
))}
{shippingMethods.map((method) => {
const methodFree = method.freeShippingThreshold !== null && subtotal >= method.freeShippingThreshold;
return (
<label key={method.id} className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="shipping"
checked={shippingMethodId === method.id}
onChange={() => setShippingMethodId(method.id)}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 flex flex-col gap-0.5">
<span className="text-body-sm text-text-primary">{method.title}</span>
<span className="text-label text-text-muted">{method.description}</span>
</span>
<span className="text-body-sm text-text-primary whitespace-nowrap">
{methodFree ? "Kostenlos" : formatPrice(method.price)}
</span>
</label>
);
})}
</Reveal>
{/* 3. Zahlungsart */}
@@ -243,45 +247,23 @@ export function CheckoutContent() {
3. Zahlungsart
</p>
<label className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="payment"
checked={paymentMethod === "card"}
onChange={() => setPaymentMethod("card")}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">Kreditkarte</span>
<span className="flex items-center gap-2 shrink-0">
<img alt="Visa" src="/icon-payment-visa.png" className="h-[1.1875rem] w-auto" />
<img alt="Mastercard" src="/icon-payment-mastercard.png" className="h-[1.1875rem] w-auto" />
<img alt="American Express" src="/icon-payment-amex.png" className="h-[1.1875rem] w-auto" />
</span>
</label>
<label className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="payment"
checked={paymentMethod === "paypal"}
onChange={() => setPaymentMethod("paypal")}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">PayPal</span>
<img alt="PayPal" src="/icon-payment-paypal.png" className="h-5 w-auto shrink-0" />
</label>
<label className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="payment"
checked={paymentMethod === "bank"}
onChange={() => setPaymentMethod("bank")}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">Überweisung</span>
<img alt="" src="/icon-payment-bank.png" className="h-[1.3125rem] w-auto shrink-0" />
</label>
{paymentMethods.map((method) => (
<label key={method.id} className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="payment"
checked={paymentMethodId === method.id}
onChange={() => setPaymentMethodId(method.id)}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
<span className="flex items-center gap-2 shrink-0">
{method.icons.map((icon, i) => (
<img key={i} alt="" src={icon} className="h-5 w-auto" />
))}
</span>
</label>
))}
<Link
href="/bestellbestaetigung"
@@ -348,7 +330,7 @@ export function CheckoutContent() {
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
</span>
</div>
<p className="text-label text-text-muted">innerhalb Deutschlands</p>
<p className="text-label text-text-muted">{selectedShipping?.description ?? "innerhalb Deutschlands"}</p>
</div>
<div className="h-px bg-border w-full" />
@@ -368,17 +350,15 @@ export function CheckoutContent() {
</div>
</div>
{/* title + description here — /cart's sidebar renders the same
CartTrustBadges docs with title only, see CartContent.tsx. */}
<div className="flex flex-col gap-4 items-start w-full">
{[
{ icon: "/icon-lock.svg", title: "Sichere Zahlung", desc: "Deine Daten sind bei uns sicher und geschützt." },
{ icon: "/icon-trust-return.png", title: "14 Tage Rückgaberecht", desc: "Nicht zufrieden? Sende deine Bestellung innerhalb von 14 Tagen zurück." },
{ icon: "/icon-trust-leaf.png", title: "Nachhaltig verpackt", desc: "Wir achten auf umweltfreundliche Materialien und plastikfreien Versand." },
].map((b) => (
<div key={b.title} className="flex gap-3 items-start w-full">
{trustBadges.map((b) => (
<div key={b.id} className="flex gap-3 items-start w-full">
<img alt="" src={b.icon} className="size-5 shrink-0 object-contain mt-0.5" />
<div className="flex-1 flex flex-col gap-0.5">
<p className="text-body-sm font-semibold text-text-primary">{b.title}</p>
<p className="text-label text-text-muted">{b.desc}</p>
<p className="text-label text-text-muted">{b.description}</p>
</div>
</div>
))}
+9 -2
View File
@@ -2,6 +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";
// robots: noindex — transactional page, same reasoning as /cart.
export const metadata: Metadata = {
@@ -13,11 +14,17 @@ export const metadata: Metadata = {
},
};
export default function CheckoutPage() {
export default async function CheckoutPage() {
const [shippingMethods, paymentMethods, trustBadges] = await Promise.all([
getShippingMethods(),
getPaymentMethods(),
getCartTrustBadges(),
]);
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CheckoutContent />
<CheckoutContent shippingMethods={shippingMethods} paymentMethods={paymentMethods} trustBadges={trustBadges} />
<TrustRow />
</main>
<Footer />
+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>
+216
View File
@@ -118,6 +118,7 @@ export type Product = {
name: string;
description: string;
price: number;
compareAtPrice: number | null;
image: string;
href: string | null;
};
@@ -128,6 +129,7 @@ type PayloadProduct = {
slug: string;
description: string | null;
price: number;
compareAtPrice: number | null;
image: { url: string } | number | null;
detailHref: string | null;
};
@@ -155,6 +157,7 @@ export async function getProducts(): Promise<Product[]> {
name: product.name,
description: product.description ?? "",
price: product.price,
compareAtPrice: product.compareAtPrice ?? null,
image: typeof product.image === "object" && product.image ? product.image.url : "",
href: product.detailHref || null,
}));
@@ -165,6 +168,219 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
return products.find((p) => p.id === slug) ?? null;
}
export type SpotlightProduct = Product & {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
};
type PayloadSpotlightProduct = PayloadProduct & {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
};
// sort: "-spotlight,-updatedAt" — same deterministic-tie-breaker pattern
// as getBlogPosts' featured post: if more than one product is accidentally
// marked spotlight, the most recently updated one wins, no error.
export async function getSpotlightProduct(): Promise<SpotlightProduct | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[spotlight][equals]": "true",
sort: "-updatedAt",
depth: "2",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getSpotlightProduct: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadSpotlightProduct[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return {
id: doc.slug,
name: doc.name,
description: doc.description ?? "",
price: doc.price,
compareAtPrice: doc.compareAtPrice ?? null,
image: typeof doc.image === "object" && doc.image ? doc.image.url : "",
href: doc.detailHref || null,
spotlightHeadline: doc.spotlightHeadline || null,
spotlightText: doc.spotlightText || null,
spotlightImage:
typeof doc.spotlightImage === "object" && doc.spotlightImage ? doc.spotlightImage.url : null,
};
}
export type TrustBadge = { id: number; title: string; description: string; icon: string };
type PayloadTrustBadge = { id: number; title: string; description: string; icon: { url: string } | number | null };
async function fetchTrustBadgeList(collection: "trust-badges" | "cart-trust-badges"): Promise<TrustBadge[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
sort: "sortOrder",
depth: "1",
limit: "50",
});
const res = await fetch(`${PAYLOAD_URL}/api/${collection}?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`fetchTrustBadgeList(${collection}): Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadTrustBadge[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
description: doc.description,
icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
}));
}
// Powers TrustRow.tsx — the horizontal "Schneller Versand /
// Versandkostenfrei / Mit Liebe verpackt" row.
export async function getTrustBadges(): Promise<TrustBadge[]> {
return fetchTrustBadgeList("trust-badges");
}
// Powers the "Sichere Zahlung / 14 Tage Rückgaberecht / Nachhaltig
// verpackt" sidebar bullets on /cart (title only) and /checkout
// (title + description) — a different list from TrustBadges.
export async function getCartTrustBadges(): Promise<TrustBadge[]> {
return fetchTrustBadgeList("cart-trust-badges");
}
export type ShippingMethod = {
id: number;
title: string;
description: string;
price: number;
freeShippingThreshold: number | null;
};
type PayloadShippingMethod = ShippingMethod & { active: boolean };
export async function getShippingMethods(): Promise<ShippingMethod[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
sort: "sortOrder",
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/shipping-methods?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getShippingMethods: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadShippingMethod[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
description: doc.description,
price: doc.price,
freeShippingThreshold: doc.freeShippingThreshold ?? null,
}));
}
export type PaymentMethod = { id: number; title: string; icons: string[] };
type PayloadPaymentMethod = {
id: number;
title: string;
active: boolean;
icons: { icon: { url: string } | number | null }[];
};
export async function getPaymentMethods(): Promise<PaymentMethod[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
sort: "sortOrder",
depth: "1",
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/payment-methods?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getPaymentMethods: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadPaymentMethod[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
icons: (doc.icons ?? [])
.map((row) => (typeof row.icon === "object" && row.icon ? row.icon.url : null))
.filter((url): url is string => Boolean(url)),
}));
}
export type WerkzeugeCard = {
id: number;
title: string;
description: string;
icon: string;
ctaLabel: string;
ctaHref: string;
};
type PayloadWerkzeugeCard = {
id: number;
title: string;
description: string;
icon: { url: string } | number | null;
ctaLabel: string;
ctaHref: string;
};
export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
sort: "sortOrder",
depth: "1",
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/werkzeuge-cards?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getWerkzeugeCards: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadWerkzeugeCard[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
description: doc.description,
icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
ctaLabel: doc.ctaLabel,
ctaHref: doc.ctaHref,
}));
}
export type LegalPageType = "impressum" | "datenschutz" | "agb" | "widerruf";
export type LegalPage = {