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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user