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:
+4
-1
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Generated
+20
@@ -8,6 +8,7 @@
|
||||
"name": "einfach-produktiv",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@payloadcms/live-preview-react": "^3.85.2",
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
@@ -1307,6 +1308,25 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@payloadcms/live-preview": {
|
||||
"version": "3.85.2",
|
||||
"resolved": "https://registry.npmjs.org/@payloadcms/live-preview/-/live-preview-3.85.2.tgz",
|
||||
"integrity": "sha512-nWOQhBv4ei3uEjJGGCo1m541fx4D8wONLv74/yjzsawDHRq6tOcOMUTQuQPIZt5xNlomYFPH9XlZfgIs3BSWdA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@payloadcms/live-preview-react": {
|
||||
"version": "3.85.2",
|
||||
"resolved": "https://registry.npmjs.org/@payloadcms/live-preview-react/-/live-preview-react-3.85.2.tgz",
|
||||
"integrity": "sha512-HMulvQRXAQp2OaqmXgZa5Sg1htN+5FC36g0vFnoYooG5XtIuRriFNPOPbokygg67AOQPo+0G0r6jPM9JTjYkXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@payloadcms/live-preview": "3.85.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/live-preview-react": "^3.85.2",
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
|
||||
Reference in New Issue
Block a user