diff --git a/app/components/RichText.tsx b/app/components/RichText.tsx
new file mode 100644
index 0000000..055afac
--- /dev/null
+++ b/app/components/RichText.tsx
@@ -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 ;
+ case "text": {
+ let el: ReactNode = node.text;
+ const format = node.format ?? 0;
+ if (format & BOLD) el = {el} ;
+ if (format & ITALIC) el = {el} ;
+ if (format & UNDERLINE) el = {el} ;
+ return {el} ;
+ }
+ case "link":
+ return (
+
+ {renderChildren(node.children, key)}
+
+ );
+ case "heading": {
+ const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+ const text = plainText(node);
+ return (
+
+ {renderChildren(node.children, key)}
+
+
+ );
+ }
+ case "list": {
+ const ListTag = node.listType === "number" ? "ol" : "ul";
+ return (
+
+ {renderChildren(node.children, key)}
+
+ );
+ }
+ case "listitem":
+ return (
+
{renderChildren(node.children, key)}
+ );
+ case "paragraph":
+ return (
+
+ {renderChildren(node.children, key)}
+
+ );
+ 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 (
+
+ {renderChildren(root.children, "root")}
+
+ );
+}
diff --git a/app/components/SectionTOC.tsx b/app/components/SectionTOC.tsx
new file mode 100644
index 0000000..d1ed7c5
--- /dev/null
+++ b/app/components/SectionTOC.tsx
@@ -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 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(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(null);
+ const overrideTimeoutRef = useRef | 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 (
+
+
+ Inhaltsübersicht
+
+ {sections.map(({ id, title }) => (
+ 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}
+
+ ))}
+
+ );
+}
diff --git a/app/datenschutz/page.tsx b/app/datenschutz/page.tsx
new file mode 100644
index 0000000..4ed2da3
--- /dev/null
+++ b/app/datenschutz/page.tsx
@@ -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 (
+ <>
+
+
+
+ Startseite
+ ›
+ Datenschutz
+
+
+ Datenschutzerklärung
+
+ Stand: Juli 2026
+
+
+
+
+
+
+ {/* 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. */}
+
+
+
+ Nachhaltigkeit ist für uns mehr als ein Schlagwort.
+
+
+ Auch im digitalen Raum gehen wir bewusst und verantwortungsvoll mit Daten um.
+
+
+
+
+
+
+ {page ? (
+
+ ) : (
+
Inhalte werden gerade aktualisiert.
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx
new file mode 100644
index 0000000..fabf250
--- /dev/null
+++ b/app/impressum/page.tsx
@@ -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 (
+ <>
+
+
+
+ Startseite
+ ›
+ Impressum
+
+
+ Impressum
+
+ Angaben gemäß § 5 TMG
+
+
+
+
+
+
+ {/* 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. */}
+
+
+
+ Nachhaltigkeit
+
+
+ Diese Website wird mit Ökostrom betrieben und ist klimafreundlich gehostet.
+
+
Für eine bessere Zukunft – für uns alle.
+
+
+
+
+
+ {page ? (
+
+ ) : (
+
Inhalte werden gerade aktualisiert.
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/app/lib/payload.ts b/app/lib/payload.ts
index 4239e0c..8d88c74 100644
--- a/app/lib/payload.ts
+++ b/app/lib/payload.ts
@@ -112,3 +112,38 @@ export async function getProductBySlug(slug: string): Promise {
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 {
+ 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 };
+}
diff --git a/app/versand/components/VersandTOC.tsx b/app/versand/components/VersandTOC.tsx
index 6674718..90c0c54 100644
--- a/app/versand/components/VersandTOC.tsx
+++ b/app/versand/components/VersandTOC.tsx
@@ -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(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 (
-
-
- Inhaltsverzeichnis
-
- {VERSAND_SECTION_IDS.map(({ id, title }) => (
-
- {title}
-
- ))}
-
- );
+ return ;
}
diff --git a/app/versand/page.tsx b/app/versand/page.tsx
index 861d676..c64cf32 100644
--- a/app/versand/page.tsx
+++ b/app/versand/page.tsx
@@ -34,7 +34,9 @@ export default function VersandPage() {