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
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { RichText } from "./RichText";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Wraps just the RichText body of a legal page (/agb, /datenschutz,
// /impressum, /widerruf) for Payload Live Preview — those 4 pages' own
// headings/sidebars/TOC are hardcoded per page, not sourced from
// `page.title` at all, so `content` is the only field that actually
// benefits from real-time editing preview. Falls back to the
// server-fetched `initialContent` until a postMessage arrives, which only
// happens at all while this page is open inside the Payload admin's Live
// Preview iframe — ordinary visitors never mount this differently from a
// plain <RichText>.
export function LiveRichText({ initialContent, quoteLabel }: { initialContent: unknown; quoteLabel?: string }) {
const { data } = useLivePreview<{ content: unknown }>({
initialData: { content: initialContent },
serverURL: PAYLOAD_URL,
depth: 2,
});
return <RichText content={data.content} quoteLabel={quoteLabel} />;
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { TestimonialsGrid } from "./TestimonialsGrid";
import { mapPayloadTestimonial, type PayloadTestimonial, type Testimonial } from "../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live Preview is document-scoped (Payload's admin has exactly one
// testimonial open at a time), but this page renders a *grid* of several —
// so unlike LiveRichText/LivePostContent (which map 1:1 to a single
// document), this only swaps in the one testimonial currently being edited
// (matched by id) and leaves the rest of the grid as initially fetched.
// initialData starts empty since we don't know which of the `testimonials`
// is open until the first postMessage arrives — acceptable because this
// component only ever mounts inside the Payload admin's own preview
// iframe (see the `isPreview` gate in todo-cards/newsletter/challenge's
// page.tsx), never for ordinary site visitors.
export function LiveTestimonialsGrid({ testimonials }: { testimonials: Testimonial[] }) {
const { data } = useLivePreview<Partial<PayloadTestimonial>>({
initialData: {},
serverURL: PAYLOAD_URL,
depth: 1,
});
const merged =
data.id !== undefined
? testimonials.map((t) => (t.id === data.id ? mapPayloadTestimonial(data as PayloadTestimonial) : t))
: testimonials;
return <TestimonialsGrid testimonials={merged} />;
}
+55
View File
@@ -0,0 +1,55 @@
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import type { Testimonial } from "../lib/payload";
// Shared by /todo-cards, /newsletter, and /challenge — all three were
// already pixel-identical (bg-muted filled card, no border, decorative
// quote-mark, quote on top with flex-1 pushing the avatar/name row to the
// bottom, hover-lift + avatar-scale), kept in sync deliberately as one
// visual pattern across pages rather than each page's own (differing)
// Figma spec for this one section. max-w-[1600px] matches Challenge's
// testimonial container cap, the widest of the three original values —
// a deliberate compromise so all three read consistently across viewports
// instead of one looking narrower than the others past ~1440px.
export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[] }) {
if (testimonials.length === 0) return null;
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
<Reveal
className="font-semibold text-h-emphasis text-text-primary text-center"
style={{ fontFamily: "var(--font-lora)" }}
>
Was andere sagen
</Reveal>
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{testimonials.map((t) => (
<RevealItem
key={t.id}
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<span
aria-hidden
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
>
</span>
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{t.quote}</p>
<div className="flex items-center gap-3">
<div className="relative size-10 shrink-0 rounded-full overflow-hidden transition-transform duration-300 group-hover:scale-110">
<Image src={t.avatar} alt={t.name} fill sizes="40px" className="object-cover" />
</div>
<div>
<p className="font-semibold text-body-sm text-text-primary">{t.name}</p>
<p className="text-body-sm text-text-muted">{t.role}</p>
</div>
</div>
</RevealItem>
))}
</RevealGroup>
</div>
</section>
);
}