Add /impressum and /datenschutz pages backed by Payload
- lib/payload.ts: getLegalPage(type) alongside the existing
getBlogPosts/getProducts fetchers
- New RichText component: small dependency-free Lexical JSON → JSX
renderer for Payload's richText fields (headings get stable
"section-N" ids for anchor/TOC linking)
- New SectionTOC: generalized from VersandTOC into a reusable
scroll-spy sidebar driven by any {id, title}[] — Datenschutz needs one
built from CMS-authored headings, not a hardcoded array. VersandTOC is
now a thin wrapper around it. Sticky positioning moved from the nav
itself to each page's sidebar wrapper, so a TOC and an extra card
(Impressum/Datenschutz both have one) scroll together as one unit
instead of the card drifting away independently
- TOC clicks set the active item immediately and hold it for ~1s over
the scroll-spy observer, covering both "target already fully visible,
nothing to scroll" and "an unrelated section flickers through the
intersection band mid-scroll"
- Both pages follow the same breadcrumb+H1+TOC-sidebar+content layout
established for /versand
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TOCSection } from "./SectionTOC";
|
||||
|
||||
// Minimal Lexical JSON → JSX renderer for Payload's richText fields.
|
||||
// Deliberately small and dependency-free (matches the project's existing
|
||||
// style — see Posts.ts's own hand-rolled extractPlainText on the Payload
|
||||
// side) rather than pulling in @payloadcms/richtext-lexical's full React
|
||||
// renderer just to walk a legal page's headings/paragraphs/lists. Covers
|
||||
// the node types real content actually uses; add more only when a page
|
||||
// genuinely needs them.
|
||||
|
||||
type LexicalNode = {
|
||||
type: string;
|
||||
children?: LexicalNode[];
|
||||
text?: string;
|
||||
format?: number;
|
||||
tag?: string;
|
||||
listType?: "bullet" | "number";
|
||||
fields?: { url?: string };
|
||||
};
|
||||
|
||||
// Lexical's text format is a bitmask — see TextFormatType in the Lexical
|
||||
// source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8).
|
||||
const BOLD = 1;
|
||||
const ITALIC = 2;
|
||||
const UNDERLINE = 8;
|
||||
|
||||
function plainText(node: LexicalNode): string {
|
||||
if (node.type === "text") return node.text ?? "";
|
||||
return (node.children ?? []).map(plainText).join("");
|
||||
}
|
||||
|
||||
// Numbered legal-page headings ("1. Verantwortlicher") get a stable
|
||||
// "section-1" id from the leading number — immune to copy edits changing
|
||||
// the heading text later, unlike a text-derived slug. Anything else
|
||||
// (headings with no leading number) falls back to a plain slugify.
|
||||
function headingId(text: string): string {
|
||||
const numbered = text.match(/^(\d+)\./);
|
||||
if (numbered) return `section-${numbered[1]}`;
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9äöüß]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
// Walks the same tree the renderer does, collecting h2 headings for a
|
||||
// SectionTOC sidebar — kept in this file (not duplicated) so the ids it
|
||||
// produces can never drift from the ones the renderer actually assigns.
|
||||
export function extractHeadings(content: unknown): TOCSection[] {
|
||||
const root = (content as { root?: LexicalNode })?.root;
|
||||
if (!root?.children) return [];
|
||||
const headings: TOCSection[] = [];
|
||||
const walk = (nodes: LexicalNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "heading" && (node.tag ?? "h2") === "h2") {
|
||||
const text = plainText(node);
|
||||
headings.push({ id: headingId(text), title: text });
|
||||
}
|
||||
if (node.children) walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(root.children);
|
||||
return headings;
|
||||
}
|
||||
|
||||
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string): ReactNode {
|
||||
if (!nodes) return null;
|
||||
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`));
|
||||
}
|
||||
|
||||
function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
switch (node.type) {
|
||||
case "linebreak":
|
||||
return <br key={key} />;
|
||||
case "text": {
|
||||
let el: ReactNode = node.text;
|
||||
const format = node.format ?? 0;
|
||||
if (format & BOLD) el = <strong key={key}>{el}</strong>;
|
||||
if (format & ITALIC) el = <em key={key}>{el}</em>;
|
||||
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
|
||||
return <span key={key}>{el}</span>;
|
||||
}
|
||||
case "link":
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={node.fields?.url ?? "#"}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
</a>
|
||||
);
|
||||
case "heading": {
|
||||
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node);
|
||||
return (
|
||||
<Tag
|
||||
key={key}
|
||||
id={Tag === "h2" ? headingId(text) : undefined}
|
||||
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
const ListTag = node.listType === "number" ? "ol" : "ul";
|
||||
return (
|
||||
<ListTag
|
||||
key={key}
|
||||
className={
|
||||
"flex flex-col gap-2 text-body text-text-body " +
|
||||
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
|
||||
}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
case "listitem":
|
||||
return (
|
||||
<li key={key}>{renderChildren(node.children, key)}</li>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<p key={key} className="text-body text-text-body">
|
||||
{renderChildren(node.children, key)}
|
||||
</p>
|
||||
);
|
||||
default:
|
||||
return renderChildren(node.children, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function RichText({ content }: { content: unknown }) {
|
||||
const root = (content as { root?: LexicalNode })?.root;
|
||||
if (!root?.children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{renderChildren(root.children, "root")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type TOCSection = { id: string; title: string };
|
||||
|
||||
// How long a click "wins" over the scroll-spy observer — long enough to
|
||||
// cover the native smooth-scroll animation triggered by the anchor href,
|
||||
// so sections passing through the viewport mid-scroll can't flicker the
|
||||
// active state away from what was actually clicked.
|
||||
const CLICK_OVERRIDE_MS = 1000;
|
||||
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
// scroll-spy sidebar but driven by CMS-authored headings instead of a
|
||||
// hardcoded array. Any future long legal/content page reuses this too.
|
||||
//
|
||||
// Not sticky itself — Impressum/Datenschutz put an extra card below this
|
||||
// in the same sidebar column, and if only this <nav> were sticky, the
|
||||
// card (a plain-flow sibling) would scroll away independently instead of
|
||||
// travelling with it. The caller wraps whatever the sidebar column
|
||||
// contains (this alone, or this + more) in `lg:sticky lg:top-32
|
||||
// lg:self-start` so the whole column moves as one unit.
|
||||
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
const [active, setActive] = useState<string>(sections[0]?.id ?? "");
|
||||
// Not state — read inside the IntersectionObserver callback without
|
||||
// needing to re-subscribe it on every click, and cleared by its own
|
||||
// timeout rather than a render.
|
||||
const overrideRef = useRef<string | null>(null);
|
||||
const overrideTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(overrideTimeoutRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// A click just fired — trust it over whatever the scroll-spy sees
|
||||
// mid-animation (sections flying past the intersection band while
|
||||
// the browser's native smooth-scroll is still catching up to the
|
||||
// clicked target), including the case where the target was
|
||||
// already fully visible and no scroll happens at all.
|
||||
if (overrideRef.current) return;
|
||||
const visible = entries.filter((entry) => entry.isIntersecting);
|
||||
if (visible.length > 0) setActive(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-120px 0px -65% 0px", threshold: 0 }
|
||||
);
|
||||
sections.forEach(({ id }) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [sections]);
|
||||
|
||||
function handleClick(id: string) {
|
||||
setActive(id);
|
||||
overrideRef.current = id;
|
||||
clearTimeout(overrideTimeoutRef.current);
|
||||
overrideTimeoutRef.current = setTimeout(() => {
|
||||
overrideRef.current = null;
|
||||
}, CLICK_OVERRIDE_MS);
|
||||
}
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:flex flex-col gap-1 w-[22.5rem] shrink-0 bg-bg-base border border-border rounded-md p-6">
|
||||
<p className="text-label font-semibold text-text-muted uppercase tracking-wide mb-2">
|
||||
Inhaltsübersicht
|
||||
</p>
|
||||
{sections.map(({ id, title }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
onClick={() => handleClick(id)}
|
||||
className={
|
||||
"px-3 py-2 rounded-sm text-body-sm transition-colors border-l-2 " +
|
||||
(active === id
|
||||
? "border-toc-active-border bg-bg-muted text-text-primary font-semibold"
|
||||
: "border-transparent text-text-muted hover:text-text-primary")
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Datenschutzerklärung",
|
||||
description: "Datenschutzerklärung von einfach produktiv — wie wir mit deinen Daten umgehen.",
|
||||
alternates: { canonical: "/datenschutz" },
|
||||
};
|
||||
|
||||
export default async function DatenschutzPage() {
|
||||
const page = await getLegalPage("datenschutz");
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Datenschutz</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Stand: Juli 2026</p>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
|
||||
{/* Static, not part of the CMS content — same reasoning as
|
||||
the Impressum page's Nachhaltigkeit card: a bespoke brand
|
||||
callout doesn't belong in the generic LegalPages richText
|
||||
field shared across all 4 legal page types. */}
|
||||
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
|
||||
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
|
||||
<p
|
||||
className="font-semibold text-body text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Nachhaltigkeit ist für uns mehr als ein Schlagwort.
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Auch im digitalen Raum gehen wir bewusst und verantwortungsvoll mit Daten um.
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:flex-1 min-w-0">
|
||||
{page ? (
|
||||
<RichText content={page.content} />
|
||||
) : (
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Impressum",
|
||||
description: "Angaben gemäß § 5 TMG für einfach produktiv.",
|
||||
alternates: { canonical: "/impressum" },
|
||||
};
|
||||
|
||||
export default async function ImpressumPage() {
|
||||
const page = await getLegalPage("impressum");
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Impressum</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Impressum
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Angaben gemäß § 5 TMG</p>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
|
||||
{/* Static, not part of the CMS content — the LegalPages
|
||||
collection's richText field is deliberately generic
|
||||
(shared shape across Impressum/Datenschutz/AGB/Widerruf),
|
||||
so a bespoke brand callout like this doesn't belong inside
|
||||
it; hardcoding it here matches how VersandSections.tsx
|
||||
keeps structural/brand elements in code and only pulls
|
||||
the actual numbers/copy that need single-sourcing from
|
||||
data. */}
|
||||
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
|
||||
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
|
||||
<p
|
||||
className="font-semibold text-body text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Nachhaltigkeit
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Diese Website wird mit Ökostrom betrieben und ist klimafreundlich gehostet.
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">Für eine bessere Zukunft – für uns alle.</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:flex-1 min-w-0">
|
||||
{page ? (
|
||||
<RichText content={page.content} />
|
||||
) : (
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -112,3 +112,38 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
|
||||
const products = await getProducts();
|
||||
return products.find((p) => p.id === slug) ?? null;
|
||||
}
|
||||
|
||||
export type LegalPageType = "impressum" | "datenschutz" | "agb" | "widerruf";
|
||||
|
||||
export type LegalPage = {
|
||||
type: LegalPageType;
|
||||
title: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
type PayloadLegalPage = {
|
||||
type: LegalPageType;
|
||||
title: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
export async function getLegalPage(type: LegalPageType): Promise<LegalPage | null> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[type][equals]": type,
|
||||
limit: "1",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getLegalPage: Payload returned ${res.status} ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadLegalPage[] } = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return null;
|
||||
return { type: doc.type, title: doc.title, content: doc.content };
|
||||
}
|
||||
|
||||
@@ -1,50 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { SectionTOC } from "../../components/SectionTOC";
|
||||
import { VERSAND_SECTION_IDS } from "./VersandSections";
|
||||
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
export function VersandTOC() {
|
||||
const [active, setActive] = useState<string>(VERSAND_SECTION_IDS[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((entry) => entry.isIntersecting);
|
||||
if (visible.length > 0) setActive(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-120px 0px -65% 0px", threshold: 0 }
|
||||
);
|
||||
VERSAND_SECTION_IDS.forEach(({ id }) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:flex flex-col gap-1 w-[22.5rem] shrink-0 bg-bg-base border border-border rounded-md p-6 sticky top-32 self-start">
|
||||
<p className="text-label font-semibold text-text-muted uppercase tracking-wide mb-2">
|
||||
Inhaltsverzeichnis
|
||||
</p>
|
||||
{VERSAND_SECTION_IDS.map(({ id, title }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={
|
||||
"px-3 py-2 rounded-sm text-body-sm transition-colors border-l-2 " +
|
||||
(active === id
|
||||
? "border-toc-active-border bg-bg-muted text-text-primary font-semibold"
|
||||
: "border-transparent text-text-muted hover:text-text-primary")
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
return <SectionTOC sections={[...VERSAND_SECTION_IDS]} />;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ export default function VersandPage() {
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<VersandTOC />
|
||||
<div className="hidden lg:block lg:sticky lg:top-32 lg:self-start">
|
||||
<VersandTOC />
|
||||
</div>
|
||||
<div className="w-full lg:flex-1 max-w-[45rem]">
|
||||
<VersandSections withAnchors />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user