Legal pages rendered admin text as bare paragraphs. LegalPage now has a honeycomb header, a sticky table of contents with scroll tracking (a collapsible one on mobile), anchor links, a reading progress bar and a revision date pulled from a "Stand:" line. lib/legal-blocks.ts derives lists, cookie/provider entries with labelled facts and safe links from plain text, so the pages stay editable in the admin without markup. Legal corrections (German law as of 2026): - Widerrufsbelehrung now follows the official model (Anlage 1 und 2 zu Art. 246a EGBGB): recipient name/address/e-mail filled from company data, deadline rule, return period, refund details, diminished value and the model withdrawal form. The incomplete notice extended the withdrawal period to up to 12 months and 14 days. Honey is no longer declared perishable. - Impressum: TMG references replaced by DDG; editorial contact gains an address. - Datenschutz: removes the self-hosted vs. hosting-provider contradiction, adds PayPal/Klarna, reviews, back-in-stock notifications, storage period, no automated decisions, withdrawal of consent and the Art. 21 objection notice; newsletter double opt-in. - AGB: payment methods and delivery area point to the live shipping page; adds contract-text and input-correction information. - Cookie policy: storage period and provider for every entry. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { motion, useReducedMotion } from "motion/react";
|
|
import { HexMark } from "@/components/honeycomb";
|
|
|
|
export interface LegalTocItem {
|
|
id: string;
|
|
label: string;
|
|
}
|
|
|
|
/**
|
|
* Inhaltsverzeichnis der Rechtsseiten. Breit: klebt neben dem Text und
|
|
* markiert den Abschnitt, in dem man gerade liest — das Sechseck gleitet
|
|
* mit (die eine bewusste Bewegung der Seite). Schmal: aufklappbar ueber dem
|
|
* Text, damit er nicht erst nach einer langen Linkliste beginnt.
|
|
*/
|
|
export function LegalToc({ items, label }: { items: LegalTocItem[]; label: string }) {
|
|
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
|
|
const reduceMotion = useReducedMotion();
|
|
|
|
useEffect(() => {
|
|
const targets = items.map((item) => document.getElementById(item.id)).filter((el): el is HTMLElement => el !== null);
|
|
// Das Band liegt im oberen Drittel: aktiv ist der Abschnitt, dessen
|
|
// Ueberschrift man gerade liest, nicht der, der unten hereinragt.
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
|
if (visible[0]) setActiveId(visible[0].target.id);
|
|
},
|
|
{ rootMargin: "-15% 0px -70% 0px" }
|
|
);
|
|
targets.forEach((el) => observer.observe(el));
|
|
return () => observer.disconnect();
|
|
}, [items]);
|
|
|
|
const links = (onDesktop: boolean) => (
|
|
<ol className="space-y-0.5">
|
|
{items.map((item) => {
|
|
const isActive = onDesktop && item.id === activeId;
|
|
return (
|
|
<li key={item.id}>
|
|
<a
|
|
href={`#${item.id}`}
|
|
aria-current={isActive ? "location" : undefined}
|
|
className={`group relative flex items-start gap-3 py-1.5 text-sm leading-snug transition-colors duration-300 ${
|
|
isActive ? "text-heading" : "text-ink/60 hover:text-heading"
|
|
}`}
|
|
>
|
|
<span className="relative mt-[0.3rem] h-2.5 w-2.5 flex-shrink-0">
|
|
<HexMark className="absolute inset-0 h-2.5 w-2.5 text-ink/15" filled={false} />
|
|
{isActive ? (
|
|
<motion.span
|
|
layoutId="legal-toc-marker"
|
|
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 36 }}
|
|
className="absolute inset-0 text-honey"
|
|
>
|
|
<HexMark className="h-2.5 w-2.5" />
|
|
</motion.span>
|
|
) : null}
|
|
</span>
|
|
<span>{item.label}</span>
|
|
</a>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<details className="group/toc mb-12 border-y border-ink/10 lg:hidden">
|
|
<summary className="flex cursor-pointer list-none items-center justify-between py-4 text-sm tracking-wide-caps text-heading [&::-webkit-details-marker]:hidden">
|
|
{label}
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4 transition-transform duration-300 group-open/toc:rotate-180" fill="none" stroke="currentColor" strokeWidth="1.6">
|
|
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</summary>
|
|
<nav aria-label={label} className="pb-5">
|
|
{links(false)}
|
|
</nav>
|
|
</details>
|
|
<nav aria-label={label} className="sticky top-28 hidden max-h-[calc(100vh-9rem)] self-start overflow-y-auto pr-2 lg:block">
|
|
<p className="mb-4 text-xs tracking-wide-caps text-ink/60">{label}</p>
|
|
{links(true)}
|
|
</nav>
|
|
</>
|
|
);
|
|
}
|