"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; // Shared between SectionTOC (desktop sidebar nav) and MobileSectionTOC // (below lg: collapsible accordion, added 2026-07-24) — both need the same // scroll-spy "active" state and click-override handling, just render it // completely differently, so the logic lives here once instead of being // duplicated per component. function useActiveSection(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); } return { active, handleClick }; } // 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 // even the site's 640px structural floor, so sm: wouldn't leave room for // a real 2-column split at Tablet widths — a deliberate exception to the // site-wide sm: consolidation, not a leftover of it. // // 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 — every caller wraps this in its own `hidden lg:flex // ... lg:sticky lg:top-32 lg:self-start` div (Impressum/Datenschutz also // stack a second "Nachhaltigkeit" card below this in that same wrapper, so // the sticky behavior has to live on the wrapper for the two to travel // together as one unit — putting it on this