Files
einfach-produktiv/app/lib/payload.ts
T
Marco 26ae4a15f4 Wire testimonials CMS collection and Payload Live Preview
Testimonials on /todo-cards, /newsletter, /challenge now come from the new
Payload testimonials collection via a shared TestimonialsGrid component,
instead of 3 separately hardcoded arrays.

Adds Next.js Draft Mode (/api/preview) plus Live-Preview-aware client
wrappers (LiveRichText, LiveTestimonialsGrid, LivePostContent) for posts,
legal pages, and testimonials — mounted only while Draft Mode is enabled,
so ordinary visitors keep getting the plain static components.
2026-07-21 19:02:20 +00:00

604 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { draftMode } from "next/headers";
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) — Draft Mode is enabled exclusively while a document is
// open in the Payload admin's Live Preview iframe, so bypassing the normal
// 60s ISR cache there doesn't affect ordinary site visitors at all.
async function livePreviewCacheOption(): Promise<{ cache: "no-store" } | { next: { revalidate: 60 } }> {
const { isEnabled } = await draftMode();
return isEnabled ? { 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<BlogPost[]> {
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): Promise<PostDetail | null> {
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}`, await livePreviewCacheOption());
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;
};
type PayloadProduct = {
id: number;
name: string;
slug: string;
description: string | null;
price: number;
compareAtPrice: number | null;
image: { url: string } | number | null;
detailHref: string | 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,
};
}
export async function getProducts(): Promise<Product[]> {
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<Product | null> {
const products = await getProducts();
return products.find((p) => p.id === slug) ?? null;
}
export type SpotlightProduct = Product & {
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
};
type PayloadSpotlightProduct = PayloadProduct & {
spotlightEyebrow: string | null;
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,
spotlightEyebrow: doc.spotlightEyebrow || 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 : "",
}));
}
// 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<TrustBadge[]> {
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<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 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 (12 handling,
// 24 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<ShippingSettings> {
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<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 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): Promise<Testimonial[]> {
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}`, await livePreviewCacheOption());
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): Promise<LegalPage | null> {
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}`, await livePreviewCacheOption());
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,
};
}