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.
This commit is contained in:
Marco
2026-07-21 19:02:20 +00:00
parent 2d6cff9f40
commit 26ae4a15f4
18 changed files with 420 additions and 305 deletions
+86 -24
View File
@@ -1,8 +1,18 @@
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;
@@ -81,31 +91,16 @@ export type PostDetail = BlogPost & {
relatedProduct: Product | null;
};
type PayloadPostDetail = PayloadPost & {
export type PayloadPostDetail = PayloadPost & {
content: unknown;
quoteLabel: string | null;
relatedProduct: PayloadProduct | 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}`, {
next: { revalidate: 60 },
});
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;
// 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,
@@ -124,6 +119,26 @@ export async function getPostBySlug(slug: string): Promise<PostDetail | 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
@@ -154,7 +169,7 @@ type PayloadProduct = {
// 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.
function mapPayloadProduct(product: PayloadProduct): Product {
export function mapPayloadProduct(product: PayloadProduct): Product {
return {
id: product.slug,
name: product.name,
@@ -494,6 +509,55 @@ export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
}));
}
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 = {
@@ -518,9 +582,7 @@ export async function getLegalPage(type: LegalPageType): Promise<LegalPage | nul
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, {
next: { revalidate: 60 },
});
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;