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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user