import { formatPrice } from "./format"; const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de"; const TENANT_SLUG = "einfach-produktiv"; // Used only by the fetchers backing Live Preview (posts, legal pages, // testimonials) — callers pass `draft: true` only while Draft Mode is // enabled (a document open in the Payload admin's Live Preview iframe), // bypassing the normal 60s ISR cache there without affecting ordinary // visitors. Deliberately does NOT call next/headers' draftMode() itself // here — this module's mapping functions/types (mapPayloadTestimonial, // mapPayloadPost, etc.) are also imported by "use client" components // (LiveTestimonialsGrid.tsx, LivePostContent.tsx), and next/headers is a // server-only import that breaks the client bundle the moment anything // in this file touches it, even indirectly. function livePreviewCacheOption(draft: boolean): { cache: "no-store" } | { next: { revalidate: 60 } } { return draft ? { cache: "no-store" } : { next: { revalidate: 60 } }; } export type BlogPost = { id: number; title: string; slug: string; category: string; readTime: number; excerpt: string; thumbnail: string | null; publishedAt: string; featured: boolean; }; type PayloadPost = { id: number; title: string; slug: string; category: { name: string } | number | null; readTime: number; excerpt: string; thumbnail: { url: string } | number | null; publishedAt: string; featured: boolean; }; // sort: "-featured,-publishedAt" — Payload's multi-field sort puts any // featured post(s) first, most-recently-published first among those, then // the rest by date. Callers that just want posts[0] as "the" featured post // (e.g. /blog) get a deterministic pick even if more than one post was // accidentally marked featured — no special-casing needed here. export async function getBlogPosts(limit = 3): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, sort: "-featured,-publishedAt", depth: "2", limit: String(limit), }); const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, { next: { revalidate: 60 }, }); if (!res.ok) { console.error(`getBlogPosts: Payload returned ${res.status} ${res.statusText}`); return []; } const data: { docs?: PayloadPost[] } = await res.json(); const docs = Array.isArray(data.docs) ? data.docs : []; return docs.map((post) => ({ id: post.id, title: post.title, slug: post.slug, category: typeof post.category === "object" && post.category ? post.category.name : "", readTime: post.readTime, excerpt: post.excerpt, thumbnail: typeof post.thumbnail === "object" && post.thumbnail ? post.thumbnail.url : null, publishedAt: post.publishedAt, featured: post.featured, })); } export type PostDetail = BlogPost & { content: unknown; /** Label for every blockquote callout in this post's content (icon + * underline included) — empty string hides that left side entirely, * the blockquote itself still renders. See RichText.tsx's "quote" case. */ quoteLabel: string; /** Powers the "Passend dazu" card at the end of the post — null hides * the card entirely, per-post choice (unlike Products.spotlight, which * is a single site-wide flag). */ relatedProduct: Product | null; }; export type PayloadPostDetail = PayloadPost & { content: unknown; quoteLabel: string | null; relatedProduct: PayloadProduct | null; }; // Shared by getPostBySlug() and LivePostContent.tsx (which re-maps the raw // document useLivePreview receives via postMessage using this same logic, // instead of duplicating the field mapping a second time). export function mapPayloadPost(doc: PayloadPostDetail): PostDetail { return { id: doc.id, title: doc.title, slug: doc.slug, category: typeof doc.category === "object" && doc.category ? doc.category.name : "", readTime: doc.readTime, excerpt: doc.excerpt, thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null, content: doc.content, publishedAt: doc.publishedAt, featured: doc.featured, quoteLabel: doc.quoteLabel ?? "", relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null, }; } export async function getPostBySlug(slug: string, options?: { draft?: boolean }): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, "where[slug][equals]": slug, depth: "2", limit: "1", }); const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, livePreviewCacheOption(Boolean(options?.draft))); if (!res.ok) { console.error(`getPostBySlug: Payload returned ${res.status} ${res.statusText}`); return null; } const data: { docs?: PayloadPostDetail[] } = await res.json(); const doc = data.docs?.[0]; if (!doc) return null; return mapPayloadPost(doc); } // Frontend-facing shape — `id` is Payload's `slug` field, not its numeric // row id. Cart items are stored in localStorage keyed by this string (see // lib/cart.ts), so slugs were chosen in the Products collection to match // the ids the old hardcoded catalog used ("todo-karten" etc.) — switching // to numeric ids here would silently orphan every existing shopper's cart. export type Product = { id: string; name: string; description: string; price: number; compareAtPrice: number | null; image: string; href: string | null; // `active` is opt-in for callers to filter by, not applied inside // getProducts()/getProductBySlug() themselves — cart, checkout, order // confirmation, and already-linked product detail pages (e.g. // TodoKartenHero/Pricing calling getProductBySlug directly) all need to // keep resolving a product regardless of its active status, unlike the // shop grid / spotlight / related-products discovery surfaces, which // filter `.filter(p => p.active)` themselves. active: boolean; updatedAt: string; spotlight: boolean; spotlightEyebrow: string | null; spotlightHeadline: string | null; spotlightText: string | null; spotlightImage: string | null; }; type PayloadProduct = { id: number; name: string; slug: string; description: string | null; price: number; compareAtPrice: number | null; image: { url: string } | number | null; detailHref: string | null; active: boolean; updatedAt: string; spotlight: boolean; spotlightEyebrow: string | null; spotlightHeadline: string | null; spotlightText: string | null; spotlightImage: { url: string } | number | null; }; // Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in // one place instead of duplicating the same field mapping, which is // exactly the kind of drift this session's Shipping Settings work was // about eliminating elsewhere. export function mapPayloadProduct(product: PayloadProduct): Product { return { id: product.slug, 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, active: product.active, updatedAt: product.updatedAt, spotlight: product.spotlight, spotlightEyebrow: product.spotlightEyebrow || null, spotlightHeadline: product.spotlightHeadline || null, spotlightText: product.spotlightText || null, spotlightImage: typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null, }; } export async function getProducts(): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, sort: "sortOrder", depth: "2", limit: "100", }); const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 }, }); if (!res.ok) { console.error(`getProducts: Payload returned ${res.status} ${res.statusText}`); return []; } const data: { docs?: PayloadProduct[] } = await res.json(); const docs = Array.isArray(data.docs) ? data.docs : []; return docs.map(mapPayloadProduct); } export async function getProductBySlug(slug: string): Promise { const products = await getProducts(); return products.find((p) => p.id === slug) ?? null; } // Derived from getProducts() (same 60s-ISR-cached fetch every other // discovery surface already uses) instead of its own separate Payload // query — also what lets the auto-spotlight rule below just be a plain // array check instead of a second round-trip. // // Auto-spotlight: with exactly 1 active product, that product IS the // spotlight, full stop — overriding any `spotlight` flag set on some // other (inactive) product. Confirmed product decision, not just a // no-manual-flag fallback. Otherwise, same deterministic tie-break as // before (most-recently-updated wins) among active products actually // flagged `spotlight`. export async function getSpotlightProduct(): Promise { const products = await getProducts(); const active = products.filter((p) => p.active); if (active.length === 1) return active[0]; const flagged = active.filter((p) => p.spotlight); if (flagged.length === 0) return null; return flagged.reduce((latest, p) => (p.updatedAt > latest.updatedAt ? p : latest)); } 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 : "", })); } // Substitutes literal "{{lieferzeit}}" / "{{kostenfreiab}}" tokens (e.g. in // a badge's "In {{lieferzeit}} bei dir." or "Ab {{kostenfreiab}} // Bestellwert innerhalb DE." copy) with the live delivery-time range / // free-shipping threshold — lets an editor reference these numbers from // free text without duplicating and hand-maintaining them, which is // exactly how the "Schneller Versand" and "Versandkostenfrei" badges used // to drift from the real numbers whenever those changed elsewhere but not // here too. The threshold comes from the lowest freeShippingThreshold // among active ShippingMethods (same rule /cart's own banner uses), not // lib/shipping.ts's separate FREE_SHIPPING_THRESHOLD constant — that // constant is a third, independent copy of the same fact and not // necessarily what actually governs checkout. function resolveShippingTokens(text: string, shipping: ShippingSettings, freeShippingThreshold: number | null): string { let result = text.replaceAll("{{lieferzeit}}", `${shipping.totalDays.min}–${shipping.totalDays.max} Werktagen`); if (freeShippingThreshold !== null) { result = result.replaceAll("{{kostenfreiab}}", formatPrice(freeShippingThreshold)); } return result; } // Powers TrustRow.tsx — the horizontal "Schneller Versand / // Versandkostenfrei / Mit Liebe verpackt" row. export async function getTrustBadges(): Promise { const [badges, shipping, shippingMethods] = await Promise.all([ fetchTrustBadgeList("trust-badges"), getShippingSettings(), getShippingMethods(), ]); const thresholds = shippingMethods .map((m) => m.freeShippingThreshold) .filter((t): t is number => t !== null); const freeShippingThreshold = thresholds.length > 0 ? Math.min(...thresholds) : null; return badges.map((b) => ({ ...b, title: resolveShippingTokens(b.title, shipping, freeShippingThreshold), description: resolveShippingTokens(b.description, shipping, freeShippingThreshold), })); } // Powers the "Sichere Zahlung / 14 Tage Rückgaberecht / Nachhaltig // 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 ShippingSettings = { handlingDays: { min: number; max: number }; transitDays: { min: number; max: number }; /** Derived here, not stored in Payload — a third independently-editable * copy of the same fact is exactly the drift this collection replaces. */ totalDays: { min: number; max: number }; }; type PayloadShippingSettings = { handlingDaysMin: number; handlingDaysMax: number; transitDaysMin: number; transitDaysMax: number; }; // Falls back to the site's long-standing real-world numbers (1–2 handling, // 2–4 transit) if Payload has no row yet or the fetch fails — same values // the old hardcoded HANDLING_DAYS/TRANSIT_DAYS_DE constants used, so // nothing regresses before this collection gets seeded/edited. const SHIPPING_SETTINGS_FALLBACK: ShippingSettings = { handlingDays: { min: 1, max: 2 }, transitDays: { min: 2, max: 4 }, totalDays: { min: 3, max: 6 }, }; export async function getShippingSettings(): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1", }); const res = await fetch(`${PAYLOAD_URL}/api/shipping-settings?${params}`, { next: { revalidate: 60 }, }); if (!res.ok) { console.error(`getShippingSettings: Payload returned ${res.status} ${res.statusText}`); return SHIPPING_SETTINGS_FALLBACK; } const data: { docs?: PayloadShippingSettings[] } = await res.json(); const doc = Array.isArray(data.docs) ? data.docs[0] : undefined; if (!doc) return SHIPPING_SETTINGS_FALLBACK; const handlingDays = { min: doc.handlingDaysMin, max: doc.handlingDaysMax }; const transitDays = { min: doc.transitDaysMin, max: doc.transitDaysMax }; return { handlingDays, transitDays, totalDays: { min: handlingDays.min + transitDays.min, max: handlingDays.max + transitDays.max }, }; } export type PaymentMethod = { id: number; title: string; icons: string[] }; type PayloadPaymentMethod = { 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 TestimonialsPage = "todo-cards" | "newsletter" | "challenge"; export type Testimonial = { id: number; quote: string; name: string; role: string; avatar: string }; export type PayloadTestimonial = { id: number; quote: string; name: string; role: string | null; avatar: { url: string } | number | null; }; // Shared by getTestimonials() and LiveTestimonialsGrid.tsx (re-maps the raw // document useLivePreview receives via postMessage using this same logic). export function mapPayloadTestimonial(doc: PayloadTestimonial): Testimonial { return { id: doc.id, quote: doc.quote, name: doc.name, role: doc.role ?? "", avatar: typeof doc.avatar === "object" && doc.avatar ? doc.avatar.url : "", }; } // Powers the identically-styled customer testimonial grids on /todo-cards, // /newsletter, and /challenge (see TestimonialsGrid.tsx) — previously 3 // hardcoded arrays kept manually in sync. The single-quote "photo band" // testimonials on /not-found and /bestellbestaetigung are a different // shape (no avatar/role) and are not part of this collection. export async function getTestimonials(page: TestimonialsPage, options?: { draft?: boolean }): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, "where[page][equals]": page, sort: "sortOrder", depth: "1", limit: "20", }); const res = await fetch(`${PAYLOAD_URL}/api/testimonials?${params}`, livePreviewCacheOption(Boolean(options?.draft))); if (!res.ok) { console.error(`getTestimonials: Payload returned ${res.status} ${res.statusText}`); return []; } const data: { docs?: PayloadTestimonial[] } = await res.json(); const docs = Array.isArray(data.docs) ? data.docs : []; return docs.map(mapPayloadTestimonial); } export type LegalPageType = "impressum" | "datenschutz" | "agb" | "widerruf"; export type LegalPage = { type: LegalPageType; title: string; content: unknown; attachment: { url: string; title: string } | null; }; type PayloadLegalPage = { type: LegalPageType; title: string; content: unknown; attachment: { url: string; title: string } | number | null; }; export async function getLegalPage(type: LegalPageType, options?: { draft?: boolean }): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, "where[type][equals]": type, depth: "1", limit: "1", }); const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, livePreviewCacheOption(Boolean(options?.draft))); if (!res.ok) { console.error(`getLegalPage: Payload returned ${res.status} ${res.statusText}`); return null; } const data: { docs?: PayloadLegalPage[] } = await res.json(); const doc = data.docs?.[0]; if (!doc) return null; return { type: doc.type, title: doc.title, content: doc.content, attachment: typeof doc.attachment === "object" && doc.attachment ? { url: doc.attachment.url, title: doc.attachment.title } : null, }; } export type EmailTemplateType = "order-confirmation" | "password-reset"; type PayloadEmailTemplate = { type: EmailTemplateType; subject: string; heading: string; bodyText: string; footerText: string | null; }; // draft:true is used by app/email-preview/[type]/page.tsx (Live Preview, // see EmailTemplates.ts in the Payload repo); the real send (orderEmail.ts, // Customers.ts's forgotPassword hook) always reads the published version. export async function getEmailTemplate( type: EmailTemplateType, options?: { draft?: boolean }, ): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, "where[type][equals]": type, limit: "1", }); const res = await fetch(`${PAYLOAD_URL}/api/email-templates?${params}`, livePreviewCacheOption(Boolean(options?.draft))); if (!res.ok) { console.error(`getEmailTemplate: Payload returned ${res.status} ${res.statusText}`); return null; } const data: { docs?: PayloadEmailTemplate[] } = await res.json(); return data.docs?.[0] ?? null; }