diff --git a/app/agb/page.tsx b/app/agb/page.tsx index b63d4c0..25cfb7a 100644 --- a/app/agb/page.tsx +++ b/app/agb/page.tsx @@ -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() {
{page ? ( - + isPreview ? : ) : (

Inhalte werden gerade aktualisiert.

)} diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts new file mode 100644 index 0000000..deec98e --- /dev/null +++ b/app/api/preview/route.ts @@ -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); +} diff --git a/app/blog/[slug]/components/LivePostContent.tsx b/app/blog/[slug]/components/LivePostContent.tsx new file mode 100644 index 0000000..135f1a2 --- /dev/null +++ b/app/blog/[slug]/components/LivePostContent.tsx @@ -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({ + initialData: initialPost as unknown as PayloadPostDetail, + serverURL: PAYLOAD_URL, + depth: 2, + }); + const post = data?.slug ? mapPayloadPost(data) : initialPost; + + return ( + <> + +
+ {post.category} + + {post.readTime} Min +
+

+ {post.title} +

+

{post.excerpt}

+
+
+ Björn +
+

+ Björn • {formatDate(post.publishedAt)} +

+
+
+ + {post.thumbnail && ( + +
+ +
+
+ )} + + + + + + ); +} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 263b9c2..b4f02af 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -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 ( <>
- -
- {post.category} - - {post.readTime} Min -
- {/* 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. */} -

- {post.title} -

-

{post.excerpt}

-
-
- Björn -
-

- Björn • {formatDate(post.publishedAt)} -

-
-
+ {isPreview ? ( + + ) : ( + <> + +
+ {post.category} + + {post.readTime} Min +
+ {/* 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. */} +

+ {post.title} +

+

{post.excerpt}

+
+
+ Björn +
+

+ Björn • {formatDate(post.publishedAt)} +

+
+
- {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. - -
- -
-
+ {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. + +
+ +
+
+ )} + + + + + )} - - {/* "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 diff --git a/app/challenge/page.tsx b/app/challenge/page.tsx index 77c0222..01546a8 100644 --- a/app/challenge/page.tsx +++ b/app/challenge/page.tsx @@ -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 (
@@ -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 ( <>
@@ -383,60 +369,11 @@ export default function ChallengePage() { {/* ── 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. */} -
-
- - - Was andere sagen - - - {/* 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`. */} - - {testimonials.map((t) => ( - - - ” - -

{t.quote}

-
-
- {t.name} -
-
-

{t.name}

-

{t.role}

-
-
-
- ))} -
- -
-
+ {isPreview ? ( + + ) : ( + + )} {/* ── Bottom CTA ── */}
diff --git a/app/components/LiveRichText.tsx b/app/components/LiveRichText.tsx new file mode 100644 index 0000000..40fc1aa --- /dev/null +++ b/app/components/LiveRichText.tsx @@ -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 . +export function LiveRichText({ initialContent, quoteLabel }: { initialContent: unknown; quoteLabel?: string }) { + const { data } = useLivePreview<{ content: unknown }>({ + initialData: { content: initialContent }, + serverURL: PAYLOAD_URL, + depth: 2, + }); + + return ; +} diff --git a/app/components/LiveTestimonialsGrid.tsx b/app/components/LiveTestimonialsGrid.tsx new file mode 100644 index 0000000..59f0892 --- /dev/null +++ b/app/components/LiveTestimonialsGrid.tsx @@ -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>({ + 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 ; +} diff --git a/app/components/TestimonialsGrid.tsx b/app/components/TestimonialsGrid.tsx new file mode 100644 index 0000000..a59a151 --- /dev/null +++ b/app/components/TestimonialsGrid.tsx @@ -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 ( +
+
+ + Was andere sagen + + + + {testimonials.map((t) => ( + + + ” + +

{t.quote}

+
+
+ {t.name} +
+
+

{t.name}

+

{t.role}

+
+
+
+ ))} +
+
+
+ ); +} diff --git a/app/datenschutz/page.tsx b/app/datenschutz/page.tsx index a1dde9d..ea27ef1 100644 --- a/app/datenschutz/page.tsx +++ b/app/datenschutz/page.tsx @@ -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() {
{page ? ( - + isPreview ? : ) : (

Inhalte werden gerade aktualisiert.

)} diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx index 1063e3e..cb26c47 100644 --- a/app/impressum/page.tsx +++ b/app/impressum/page.tsx @@ -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() {
{page ? ( - + isPreview ? : ) : (

Inhalte werden gerade aktualisiert.

)} diff --git a/app/lib/payload.ts b/app/lib/payload.ts index eaacc44..4af7e99 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -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 { - 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 { }; } +export async function getPostBySlug(slug: string): Promise { + 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 { })); } +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 { + 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 -
- - Was andere sagen - - - - {testimonials.map((t) => ( - - - ” - -

{t.quote}

-
-
- {t.name} -
-
-

{t.name}

-

{t.role}

-
-
-
- ))} -
-
-
- ); -} diff --git a/app/newsletter/page.tsx b/app/newsletter/page.tsx index b63bd6b..92662fc 100644 --- a/app/newsletter/page.tsx +++ b/app/newsletter/page.tsx @@ -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 ( <>
- + {isPreview ? ( + + ) : ( + + )} -
- - Was andere sagen - - - - {testimonials.map((t) => ( - - - ” - -

{t.quote}

-
-
- {t.name} -
-
-

{t.name}

-

{t.role}

-
-
-
- ))} -
-
- - ); -} diff --git a/app/todo-cards/page.tsx b/app/todo-cards/page.tsx index d09ca93..5c7255d 100644 --- a/app/todo-cards/page.tsx +++ b/app/todo-cards/page.tsx @@ -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 ( <>
- + {isPreview ? ( + + ) : ( + + )}