From 1b434fe23e21b1b15ace1658aa39a652738b5648 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 19 Jul 2026 17:38:02 +0000 Subject: [PATCH] Add /impressum and /datenschutz pages backed by Payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/components/RichText.tsx | 146 ++++++++++++++++++++++++++ app/components/SectionTOC.tsx | 94 +++++++++++++++++ app/datenschutz/page.tsx | 72 +++++++++++++ app/impressum/page.tsx | 77 ++++++++++++++ app/lib/payload.ts | 35 ++++++ app/versand/components/VersandTOC.tsx | 48 +-------- app/versand/page.tsx | 4 +- 7 files changed, 429 insertions(+), 47 deletions(-) create mode 100644 app/components/RichText.tsx create mode 100644 app/components/SectionTOC.tsx create mode 100644 app/datenschutz/page.tsx create mode 100644 app/impressum/page.tsx 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