From d472eb546faf84ebf366a23c90a623614a6298d9 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 19 Jul 2026 23:15:46 +0000 Subject: [PATCH] Move trust badges, shipping/payment methods, Werkzeuge cards, product spotlight into CMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/cart/components/CartContent.tsx | 40 ++-- app/cart/components/FreeShippingBanner.tsx | 22 +- app/cart/page.tsx | 22 +- app/checkout/components/CheckoutContent.tsx | 144 ++++++------- app/checkout/page.tsx | 11 +- app/components/AddToCartButton.tsx | 8 +- app/components/ProductSpotlight.tsx | 61 +++--- app/components/Tools.tsx | 52 ++--- app/components/TrustRow.tsx | 29 +-- app/lib/payload.ts | 216 ++++++++++++++++++++ 10 files changed, 419 insertions(+), 186 deletions(-) diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx index 8c58da5..26fccdb 100644 --- a/app/cart/components/CartContent.tsx +++ b/app/cart/components/CartContent.tsx @@ -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() { ) : ( <>
- +
{/* Cart card — lg:-only split from the sidebar (same "wide content @@ -179,8 +195,8 @@ export function CartContent() {

- {shipping === 0 - ? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands` + {shipping === 0 && freeShippingThreshold !== null + ? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands` : "innerhalb Deutschlands"}

@@ -214,15 +230,13 @@ export function CartContent() { + {/* Title only — /checkout renders the same CartTrustBadges + docs with description too, see CheckoutContent.tsx. */}
- {[ - { 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) => ( -
+ {trustBadges.map((b) => ( +
- {b.text} + {b.title}
))}
diff --git a/app/cart/components/FreeShippingBanner.tsx b/app/cart/components/FreeShippingBanner.tsx index 2e7d8e3..a9a303e 100644 --- a/app/cart/components/FreeShippingBanner.tsx +++ b/app/cart/components/FreeShippingBanner.tsx @@ -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 ; +} + +function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; threshold: number }) { + const reached = subtotal >= threshold; const [phase, setPhase] = useState(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 diff --git a/app/cart/page.tsx b/app/cart/page.tsx index 69fcbe0..e1f03fa 100644 --- a/app/cart/page.tsx +++ b/app/cart/page.tsx @@ -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 ( <>
- +
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index 38e80b6..5894523 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -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(shippingMethods[0]?.id ?? null); + const [paymentMethodId, setPaymentMethodId] = useState(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

- {( - [ - { id: "standard", label: "Standardversand (2–4 Werktage)", price: SHIPPING_COST }, - { id: "express", label: "Expressversand (1–2 Werktage)", price: EXPRESS_SHIPPING_COST }, - ] as const - ).map((option) => ( - - ))} + {shippingMethods.map((method) => { + const methodFree = method.freeShippingThreshold !== null && subtotal >= method.freeShippingThreshold; + return ( + + ); + })} {/* 3. Zahlungsart */} @@ -243,45 +247,23 @@ export function CheckoutContent() { 3. Zahlungsart

- - - - - + {paymentMethods.map((method) => ( + + ))}
-

innerhalb Deutschlands

+

{selectedShipping?.description ?? "innerhalb Deutschlands"}

@@ -368,17 +350,15 @@ export function CheckoutContent() {
+ {/* title + description here — /cart's sidebar renders the same + CartTrustBadges docs with title only, see CartContent.tsx. */}
- {[ - { 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) => ( -
+ {trustBadges.map((b) => ( +

{b.title}

-

{b.desc}

+

{b.description}

))} diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx index acff019..2959f7c 100644 --- a/app/checkout/page.tsx +++ b/app/checkout/page.tsx @@ -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 ( <>
- +
diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index 6e986d6..58f357c 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -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 | 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); diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index de48d31..3185401 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -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 (
ToDo-Karten Set - ToDo-Karten – Kleine Karten. Große Wirkung. + {product.spotlightHeadline || product.name}

- 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}

+ {product.compareAtPrice && product.compareAtPrice > product.price && ( +

{formatPrice(product.compareAtPrice)}

+ )}

{formatPrice(product.price)}

inkl. MwSt. zzgl. Versand

@@ -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. */} - - - Mehr erfahren - + + {product.href && ( + + Mehr erfahren + + )}
diff --git a/app/components/Tools.tsx b/app/components/Tools.tsx index cbe72de..e5259c8 100644 --- a/app/components/Tools.tsx +++ b/app/components/Tools.tsx @@ -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 (
@@ -44,20 +34,12 @@ export function Tools() { {tools.map((tool) => ( - {/* Icon */} -
-
-
- -
-
+ {/* Icon — uniform box, pre-flipped/rotated source asset */} +
+
{/* Card content — self-stretch + h-full + justify-between so @@ -85,10 +67,10 @@ export function Tools() {

- → {tool.cta.label} + → {tool.ctaLabel}
diff --git a/app/components/TrustRow.tsx b/app/components/TrustRow.tsx index 40503ac..77c64e8 100644 --- a/app/components/TrustRow.tsx +++ b/app/components/TrustRow.tsx @@ -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 (
{items.map((item, i) => ( -
+
{i > 0 &&
}

{item.title}

-

{item.desc}

+

{item.description}

diff --git a/app/lib/payload.ts b/app/lib/payload.ts index f3e9545..7d1f06a 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -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 { 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = {