cc935420ec
Tablet layout (768-1023px) hit the md: grid switch exactly where the fluid clamp() tokens were already at their floor, leaving no room to shrink. Moves the fluid floor and structural breakpoint down together to sm: (640px) so real tablets always get the fluid, desktop-like structure; consolidates the ad-hoc md:+lg: patchwork in Hero/About/ Newsletter/Footer/Tools back onto one line, leaving documented lg: exceptions where content genuinely doesn't fit yet. Also shortens the Hero heading to a single sentence with no trailing period (the brand's orange dot already renders one, animated).
147 lines
6.3 KiB
TypeScript
147 lines
6.3 KiB
TypeScript
"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<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);
|
|
}
|
|
|
|
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 <nav> instead would leave
|
|
// that card behind as a plain-flow sibling scrolling past a now-fixed nav).
|
|
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
|
const { active, handleClick } = useActiveSection(sections);
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Below lg: only — a collapsible accordion instead of the sidebar nav
|
|
// (which is `hidden` entirely below lg:, see SectionTOC's own comment on
|
|
// why a real 2-column split doesn't fit there). Added 2026-07-24: these
|
|
// legal pages had no on-page navigation aid at all on Mobile/Tablet, which
|
|
// is exactly where scanning a long legal document by scrolling is hardest.
|
|
// Native <details>/<summary> — no extra open/close state needed, and it
|
|
// stays open after a click so jumping between sections doesn't require
|
|
// reopening it each time. Render this as its own element in the page
|
|
// (typically right after the heading, before the two-column content row),
|
|
// not nested inside a parent that's itself `hidden lg:...` — that would
|
|
// hide this too regardless of its own lg:hidden class.
|
|
export function MobileSectionTOC({ sections }: { sections: TOCSection[] }) {
|
|
const { active, handleClick } = useActiveSection(sections);
|
|
if (sections.length === 0) return null;
|
|
|
|
return (
|
|
<details className="lg:hidden w-full bg-bg-base border border-border rounded-md p-4 open:pb-2">
|
|
<summary className="text-label font-semibold text-text-muted uppercase tracking-wide cursor-pointer select-none">
|
|
Inhaltsübersicht
|
|
</summary>
|
|
<div className="flex flex-col gap-1 mt-3">
|
|
{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>
|
|
))}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|