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
+4 -1
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
@@ -17,6 +19,7 @@ export const metadata: Metadata = {
export default async function AgbPage() {
const page = await getLegalPage("agb");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
return (
<>
@@ -60,7 +63,7 @@ export default async function AgbPage() {
<div className="w-full lg:flex-1 min-w-0">
{page ? (
<RichText content={page.content} />
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
+28
View File
@@ -0,0 +1,28 @@
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { NextRequest } from "next/server";
// Entered only via the `livePreview.url` link Payload puts in its admin
// (posts/legal-pages/testimonials, see payload.config.ts and those
// collections' own `admin.livePreview.url` resolvers) — enables Draft Mode
// so the target page renders its Live-Preview-aware components (see the
// `isPreview` checks in those pages' page.tsx), then redirects into the
// actual page. `path` is never redirected to as-is (open-redirect risk);
// it's validated to be an internal path first.
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const secret = searchParams.get("secret");
const path = searchParams.get("path");
if (!secret || secret !== process.env.PAYLOAD_PREVIEW_SECRET) {
return new Response("Invalid secret", { status: 401 });
}
if (!path || !path.startsWith("/") || path.startsWith("//")) {
return new Response("Invalid path", { status: 400 });
}
const draft = await draftMode();
draft.enable();
redirect(path);
}
@@ -0,0 +1,64 @@
"use client";
import Image from "next/image";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { Reveal } from "../../../components/Reveal";
import { RichText } from "../../../components/RichText";
import { formatDate } from "../../../lib/format";
import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live-previewable subset of the blog detail page: title/category/readTime/
// excerpt/byline, the thumbnail, and the RichText body — the fields an
// editor actually watches update while typing. The author bio card,
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
// aren't post-specific content (bio) or are about a *different* post
// (nextPost), not the document currently open in the admin.
export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
const { data } = useLivePreview<PayloadPostDetail>({
initialData: initialPost as unknown as PayloadPostDetail,
serverURL: PAYLOAD_URL,
depth: 2,
});
const post = data?.slug ? mapPayloadPost(data) : initialPost;
return (
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
<p
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-playfair)" }}
>
{post.title}
</p>
<p className="text-body text-text-muted">{post.excerpt}</p>
<div className="flex items-center gap-3 pt-1">
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
</div>
<p className="text-body-sm text-text-primary">
Björn <span className="text-text-muted"> {formatDate(post.publishedAt)}</span>
</p>
</div>
</Reveal>
{post.thumbnail && (
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
</div>
</Reveal>
)}
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} quoteLabel={post.quoteLabel} />
</Reveal>
</>
);
}
+54 -43
View File
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { RichText } from "../../components/RichText";
import { LivePostContent } from "./components/LivePostContent";
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
import { formatDate } from "../../lib/format";
@@ -45,57 +47,66 @@ export default async function BlogDetailPage({
// showing a fake/duplicate card when this is the only post.
const otherPosts = await getBlogPosts(4);
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
const { isEnabled: isPreview } = await draftMode();
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
{/* Playfair (not Lora), matching the actual Figma title's font —
same "hero headline" family as Hero.tsx/TodoKartenHero.tsx/
WeeklyImpulsesHero.tsx use, not the Lora section-heading style
the legal pages use. Sized down from text-display (Figma's
literal 44px), but text-h-feature (max 40px) read too small
no named token sits between the two, so this is a one-off
fluid(36, 48) clamp using the project's own fluid.ts formula
(768px Tablet floor → 1440px Desktop cap), landing between
them instead of jumping all the way back to text-display. */}
<p
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-playfair)" }}
>
{post.title}
</p>
<p className="text-body text-text-muted">{post.excerpt}</p>
<div className="flex items-center gap-3 pt-1">
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
</div>
<p className="text-body-sm text-text-primary">
Björn <span className="text-text-muted"> {formatDate(post.publishedAt)}</span>
</p>
</div>
</Reveal>
{isPreview ? (
<LivePostContent initialPost={post} />
) : (
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
{/* Playfair (not Lora), matching the actual Figma title's font
same "hero headline" family as Hero.tsx/TodoKartenHero.tsx/
WeeklyImpulsesHero.tsx use, not the Lora section-heading style
the legal pages use. Sized down from text-display (Figma's
literal 44px), but text-h-feature (max 40px) read too small —
no named token sits between the two, so this is a one-off
fluid(36, 48) clamp using the project's own fluid.ts formula
(768px Tablet floor → 1440px Desktop cap), landing between
them instead of jumping all the way back to text-display. */}
<p
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-playfair)" }}
>
{post.title}
</p>
<p className="text-body text-text-muted">{post.excerpt}</p>
<div className="flex items-center gap-3 pt-1">
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
</div>
<p className="text-body-sm text-text-primary">
Björn <span className="text-text-muted"> {formatDate(post.publishedAt)}</span>
</p>
</div>
</Reveal>
{post.thumbnail && (
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
// edge (an earlier version guessed that, which came out far too
// wide) and not capped to the body column either.
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
</div>
</Reveal>
{post.thumbnail && (
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
// edge (an earlier version guessed that, which came out far too
// wide) and not capped to the body column either.
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
</div>
</Reveal>
)}
<Reveal delay={0.15} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} quoteLabel={post.quoteLabel} />
</Reveal>
</>
)}
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} quoteLabel={post.quoteLabel} />
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
not a hardcoded flagship-product link. Hidden entirely if the
post has no related product, or that product has no detail
+13 -76
View File
@@ -1,8 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Footer } from "../components/Footer";
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
import { TestimonialsGrid } from "../components/TestimonialsGrid";
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
import { getTestimonials } from "../lib/payload";
const title = "7-Tage-Challenge Mehr Klarheit in 7 Tagen";
const description =
@@ -118,27 +122,6 @@ const benefits = [
{ title: "Gelassener leben", desc: "Weniger Stress, mehr Zeit für die Dinge, die dir wichtig sind." },
];
const testimonials = [
{
avatar: "/avatar-1.jpg",
quote: "„Die 7-Tage-Challenge hat mir geholfen, wieder klar zu sehen und mit kleinen Schritten wirklich etwas zu verändern.“",
name: "Sarah M.",
role: "Marketing Managerin",
},
{
avatar: "/avatar-2.jpg",
quote: "„Kurz, konkret und unglaublich wirkungsvoll. Ich habe direkt mehr Fokus und weniger Druck im Kopf.“",
name: "Thomas K.",
role: "Selbständiger Berater",
},
{
avatar: "/avatar-3.jpg",
quote: "„Endlich eine Challenge, die nicht überfordert, sondern genau die richtigen Impulse gibt jeden Tag.“",
name: "Miriam L.",
role: "Projektleiterin",
},
];
function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
return (
<div className="flex flex-col gap-2 w-full">
@@ -185,7 +168,10 @@ function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: str
);
}
export default function ChallengePage() {
export default async function ChallengePage() {
const testimonials = await getTestimonials("challenge");
const { isEnabled: isPreview } = await draftMode();
return (
<>
<main className="flex flex-col flex-1 bg-[#f5f0e8]">
@@ -383,60 +369,11 @@ export default function ChallengePage() {
</section>
{/* ── Was andere sagen ── */}
{/* max-w-[1600px], not this page's other sections' 1280px — a
deliberate compromise with /todo-cards's and /newsletter's
unbounded fluid width, so all three testimonial sections cap
at the same width instead of Challenge's reading narrower on
wide viewports. Only this one section's cap changed, not the
rest of the page. */}
<section className="bg-white w-full py-14 lg:py-20">
<div className="px-8 lg:px-[5rem] max-w-[1600px] mx-auto flex flex-col gap-10">
<Reveal
className="font-semibold text-[#222221] text-center"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
>
Was andere sagen
</Reveal>
{/* Same style + micro-interactions as /todo-cards's and
/newsletter's identically-styled testimonial cards (kept in
sync deliberately): decorative quote-mark, hover-lift on
the card, avatar scale on the same hover via `group`. */}
<RevealGroup className="flex flex-col lg:flex-row gap-6">
{testimonials.map((t) => (
<RevealItem
key={t.name}
className="group relative flex-1 bg-[#f5f0e8] 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="text-[#222221] text-[0.95rem] leading-[1.6] flex-1 pr-8">{t.quote}</p>
<div className="flex items-center gap-3">
<div className="relative size-10 shrink-0 rounded-full overflow-hidden">
<Image
alt={t.name}
src={t.avatar}
fill
sizes="40px"
className="object-cover transition-transform duration-300 group-hover:scale-110"
/>
</div>
<div>
<p className="font-semibold text-[#222221] text-[0.9rem]">{t.name}</p>
<p className="text-[#777] text-[0.8rem]">{t.role}</p>
</div>
</div>
</RevealItem>
))}
</RevealGroup>
</div>
</section>
{isPreview ? (
<LiveTestimonialsGrid testimonials={testimonials} />
) : (
<TestimonialsGrid testimonials={testimonials} />
)}
{/* ── Bottom CTA ── */}
<section className="w-full py-14 lg:py-16">
+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>
);
}
+4 -1
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
@@ -16,6 +18,7 @@ export const metadata: Metadata = {
export default async function DatenschutzPage() {
const page = await getLegalPage("datenschutz");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
return (
<>
@@ -60,7 +63,7 @@ export default async function DatenschutzPage() {
<div className="w-full lg:flex-1 min-w-0">
{page ? (
<RichText content={page.content} />
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
+4 -1
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
@@ -16,6 +18,7 @@ export const metadata: Metadata = {
export default async function ImpressumPage() {
const page = await getLegalPage("impressum");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
return (
<>
@@ -65,7 +68,7 @@ export default async function ImpressumPage() {
<div className="w-full lg:flex-1 min-w-0">
{page ? (
<RichText content={page.content} />
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
+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;
@@ -1,74 +0,0 @@
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
const testimonials = [
{
quote:
"„Die wöchentlichen Impulse sind mein fester Start in die Woche. Kurz, hilfreich und immer genau richtig.“",
name: "Sarah M.",
role: "Marketing Managerin",
avatar: "/avatar-weekly-1.png",
},
{
quote: "„Endlich mal Tipps, die man wirklich umsetzen kann. Jeden Mittwoch freue ich mich auf die Mail.“",
name: "Thomas K.",
role: "Selbstständiger Berater",
avatar: "/avatar-weekly-2.png",
},
{
quote: "„Die Impulse helfen mir, den Fokus zu behalten und das Wesentliche nicht aus den Augen zu verlieren.“",
name: "Miriam L.",
role: "Projektleiterin",
avatar: "/avatar-weekly-3.png",
},
];
// Same card style as /challenge and /todo-cards's testimonials (bg-muted,
// no border, decorative quote-mark, quote on top with flex-1,
// avatar+name+role row pinned to the bottom, hover-lift + avatar scale) —
// this page's own Figma spec actually used a different bordered pattern,
// but site-wide consistency across all three testimonial sections was
// explicitly requested over per-page Figma fidelity here. No pagination
// dots either, for the same reason — neither of the other two pages has
// them. max-w-[1600px] matches Challenge's testimonial container cap —
// see the identical comment in /todo-cards's Testimonials.tsx.
export function Testimonials() {
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.name}
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>
);
}
+13 -3
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { WeeklyImpulsesHero } from "./components/WeeklyImpulsesHero";
import { HowItWorks } from "./components/HowItWorks";
import { WeeklyBenefits } from "./components/WeeklyBenefits";
import { Testimonials } from "./components/Testimonials";
import { Newsletter } from "../components/Newsletter";
import { Footer } from "../components/Footer";
import { TestimonialsGrid } from "../components/TestimonialsGrid";
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
import { getTestimonials } from "../lib/payload";
const title = "Impulse & Tipps Wöchentliche Klarheit für deinen Alltag";
const description =
@@ -35,14 +38,21 @@ export const metadata: Metadata = {
// card). Both point at the same underlying thing (signing up for the
// weekly newsletter), so this page resolves both at once instead of
// picking a new URL and leaving one of them still dangling.
export default function NewsletterPage() {
export default async function NewsletterPage() {
const testimonials = await getTestimonials("newsletter");
const { isEnabled: isPreview } = await draftMode();
return (
<>
<main className="flex flex-col flex-1">
<WeeklyImpulsesHero />
<HowItWorks />
<WeeklyBenefits />
<Testimonials />
{isPreview ? (
<LiveTestimonialsGrid testimonials={testimonials} />
) : (
<TestimonialsGrid testimonials={testimonials} />
)}
<Newsletter
title="Wöchentliche Impulse für mehr Klarheit"
description="Melde dich jetzt an und erhalte jeden Mittwoch neue Impulse & Tipps direkt in dein Postfach."
@@ -1,78 +0,0 @@
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
const testimonials = [
{
quote:
"„Die ToDo-Karten sind mein täglicher Begleiter. Endlich mehr Fokus auf das, was wirklich wichtig ist.“",
name: "Sarah M.",
role: "Marketing Managerin",
avatar: "/avatar-todo-1.png",
},
{
quote: "„Ein simples Konzept, das mein Arbeiten komplett verändert hat. Klare Empfehlung!“",
name: "Thomas K.",
role: "Selbstständiger Berater",
avatar: "/avatar-todo-2.png",
},
{
quote: "„Weniger Chaos im Kopf, mehr Struktur im Alltag. Die Karten helfen mir jeden Tag.“",
name: "Miriam L.",
role: "Projektleiterin",
avatar: "/avatar-todo-3.png",
},
];
// Card layout/style matches app/challenge/page.tsx's and
// app/newsletter/'s testimonial sections exactly (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) — all
// three kept in sync deliberately as one shared visual pattern, overriding
// each page's own (differing) Figma spec for this one section. Only the
// photos differ per page (each has its own Figma-sourced avatars).
// max-w-[1600px] on the inner wrapper matches Challenge's testimonial
// container cap — without it this section's fluid px-[layout-padding-x]
// alone has no upper bound, so cards kept growing wider than Challenge's
// past ~1440px viewports (capped at 1280px there) and read as
// inconsistently narrower on Challenge. 1600px is a deliberate compromise
// between the two, not either page's original value.
export function Testimonials() {
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.name}
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>
);
}
+13 -3
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { TodoKartenHero } from "./components/TodoKartenHero";
import { HowItWorks } from "./components/HowItWorks";
import { Focus } from "./components/Focus";
import { Testimonials } from "./components/Testimonials";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { TestimonialsGrid } from "../components/TestimonialsGrid";
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
import { getTestimonials } from "../lib/payload";
const title = "ToDo-Karten Kleine Karten. Große Wirkung.";
const description =
@@ -29,14 +32,21 @@ export const metadata: Metadata = {
},
};
export default function TodoCardsPage() {
export default async function TodoCardsPage() {
const testimonials = await getTestimonials("todo-cards");
const { isEnabled: isPreview } = await draftMode();
return (
<>
<main className="flex flex-col flex-1">
<TodoKartenHero />
<HowItWorks />
<Focus />
<Testimonials />
{isPreview ? (
<LiveTestimonialsGrid testimonials={testimonials} />
) : (
<TestimonialsGrid testimonials={testimonials} />
)}
<Pricing />
</main>
<Footer />
+4 -1
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
@@ -17,6 +19,7 @@ export const metadata: Metadata = {
export default async function WiderrufPage() {
const page = await getLegalPage("widerruf");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
return (
<>
@@ -60,7 +63,7 @@ export default async function WiderrufPage() {
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
{page ? (
<RichText content={page.content} />
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}