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