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:
Marco
2026-07-19 17:38:02 +00:00
parent 8c397c5dcb
commit 1b434fe23e
7 changed files with 429 additions and 47 deletions
+2 -46
View File
@@ -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]} />;
}