Compare commits

..

2 Commits

Author SHA1 Message Date
Marco f5df384b13 fix(Navbar): stop backdrop-blur from breaking NewsletterModal's fixed positioning
backdrop-filter (backdrop-blur-md, applied to the sticky header once
scrolled) creates a new containing block for position:fixed descendants,
same as transform does. NewsletterModal was rendered inside that header,
so its `fixed inset-0` backdrop silently stopped being fixed to the
viewport once scrolled and became fixed to the 100px header instead,
clipping the modal to the top of the page. Moved the modal to be a
sibling of <header> instead of a child.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:03:12 +00:00
Marco 7a3ae0ea53 Add /widerruf and /agb legal pages, fix newsletter modal scroll jank
- New /widerruf page: CMS-driven content via getLegalPage("widerruf"),
  sidebar TOC + "Nachhaltig handeln" callout, TrustRow, and a download
  card for the Muster-Widerrufsformular PDF attachment.
- New /agb page, same legal-page shell pattern as Impressum/Datenschutz.
- lib/payload.ts: getLegalPage now also resolves the optional `attachment`
  media relation (depth=1) for legal pages that have a downloadable file.
- NewsletterModal: replaced the body overflow/position scroll-lock hacks
  (which fought with the sticky Navbar and the global scroll-smooth CSS)
  with a wheel/touch event interceptor that never touches scroll position
  at all, plus `preventScroll: true` on all modal/drawer focus() calls to
  stop the browser's implicit scrollIntoView from nudging the page for
  elements near the sticky navbar's reserved scroll-padding-top zone.
  Same preventScroll fix applied to VersandModal and the Navbar mobile
  drawer for consistency.
- globals.css: scrollbar-gutter: stable, so a modal's overflow:hidden
  lock never causes a scrollbar-width layout shift.
- Divider: stagger its reveal animation slightly (delay 0.3s) behind the
  section above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:57:28 +00:00
9 changed files with 286 additions and 21 deletions
+74
View File
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
export const metadata: Metadata = {
title: "AGB",
description: "Allgemeine Geschäftsbedingungen von einfach produktiv.",
alternates: { canonical: "/agb" },
};
export default async function AgbPage() {
const page = await getLegalPage("agb");
const headings = page ? extractHeadings(page.content) : [];
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
<span></span>
<span className="text-text-primary">AGB</span>
</p>
<p
className="font-semibold text-h-feature text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Allgemeine Geschäftsbedingungen
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
</Reveal>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
<SectionTOC sections={headings} />
{/* Static, not part of the CMS content — same reasoning as
the Impressum/Datenschutz pages' own callout cards. */}
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
<p
className="font-semibold text-body text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Unser Anspruch
</p>
<p className="text-body-sm text-text-muted">
Wir entwickeln Produkte, die dich im Alltag wirklich weiterbringen mit Klarheit,
Qualität und einem bewussten Umgang mit Ressourcen.
</p>
<div className="h-[0.125rem] w-8 bg-brand" />
</div>
</div>
<div className="w-full lg:flex-1 min-w-0">
{page ? (
<RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
</div>
</div>
<TrustRow />
</main>
<Footer />
</>
);
}
+4 -1
View File
@@ -25,7 +25,10 @@ function Arrow() {
export function Divider() {
return (
<Reveal className="flex items-center justify-center flex-wrap gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center">
<Reveal
delay={0.3}
className="flex items-center justify-center flex-wrap gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center"
>
{/* Word + its trailing icon are grouped into one shrink-0 flex unit
so flex-wrap only ever breaks BETWEEN pairs, never leaving an
+6 -3
View File
@@ -33,12 +33,15 @@ export function Footer() {
</div>
{/* Social handle — Lora Regular (weight 400) */}
<p
className="font-normal text-bg-white text-h-small text-center leading-[1.2] whitespace-nowrap shrink-0"
<a
href="https://www.instagram.com/einfach.produktiv/"
target="_blank"
rel="noopener noreferrer"
className="font-normal text-bg-white text-h-small text-center leading-[1.2] whitespace-nowrap shrink-0 hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)", fontWeight: 400 }}
>
@einfach.produktiv
</p>
</a>
{/* Legal links — wrap + center below md instead of a rigid row */}
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 shrink-0">
+21 -6
View File
@@ -9,7 +9,7 @@ import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
const SCROLL_DURATION = 800; // ms
const SCROLL_DURATION = 200; // ms
function smoothScrollTo(targetY: number) {
const startY = window.scrollY;
@@ -224,13 +224,13 @@ export function Navbar() {
const focusables = panel?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled])'
);
focusables?.[0]?.focus();
focusables?.[0]?.focus({ preventScroll: true });
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setMobileOpen(false);
hamburgerRef.current?.focus();
hamburgerRef.current?.focus({ preventScroll: true });
return;
}
if (e.key !== "Tab" || !focusables || focusables.length === 0) return;
@@ -238,10 +238,10 @@ export function Navbar() {
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
last.focus({ preventScroll: true });
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
first.focus({ preventScroll: true });
}
};
@@ -252,6 +252,20 @@ export function Navbar() {
const closeMobile = () => setMobileOpen(false);
return (
// Fragment, not just the <header> — NewsletterModal must NOT be a
// descendant of it. `backdrop-blur-md` below is a `backdrop-filter`,
// which (like `transform`) makes its element a new containing block
// for `position: fixed` descendants per spec. Since the header only
// gets that class once `scrolled` is true, the modal's `fixed inset-0`
// backdrop silently stopped being fixed to the viewport and became
// fixed to the 100px-tall header instead — centering math then ran
// against that instead of the viewport, clipping the modal to the top
// of the page. Exactly reproduced whenever the modal was opened while
// scrolled (e.g. after clicking an anchor link), never at the very
// top of the page (scrollY <= 8, no backdrop-blur yet) — which is why
// it looked tied to "clicked an anchor first" rather than to scroll
// position itself.
<>
<header
className={`sticky top-0 z-50 w-full h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
scrolled || mobileOpen
@@ -481,8 +495,9 @@ export function Navbar() {
</Link>
</div>
</div>
</header>
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
</header>
</>
);
}
+43 -7
View File
@@ -34,13 +34,40 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Body scroll lock while open.
// Background scroll lock while open — intercepts and cancels the wheel/
// touch input that would cause scrolling, instead of toggling
// overflow/position on body or html. Two earlier approaches (plain
// overflow:hidden on body; position:fixed with a negative top offset to
// compensate) each fixed one symptom while causing another — overflow
// alone reset scrollY to 0 for a frame when opened away from the top of
// the page (e.g. parked at #ueber-bjoern), and position:fixed took body
// out of normal flow, which detached the Navbar's `position: sticky`
// from its scrolling container and made it visibly snap. Neither is a
// risk here: scrollY, overflow and layout are never touched at all, so
// there's nothing that can jump, reflow, or need restoring on close —
// the events that would move the page just never get to.
// `{ passive: false }` is required for preventDefault() to have any
// effect on wheel/touchmove. Events that originate inside the dialog
// (which has its own overflow-y-auto) are let through untouched, so the
// modal's own content still scrolls normally.
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const isInsideDialog = (target: EventTarget | null) =>
target instanceof Node && !!dialogRef.current?.contains(target);
const onWheel = (e: WheelEvent) => {
if (!isInsideDialog(e.target)) e.preventDefault();
};
const onTouchMove = (e: TouchEvent) => {
if (!isInsideDialog(e.target)) e.preventDefault();
};
document.addEventListener("wheel", onWheel, { passive: false });
document.addEventListener("touchmove", onTouchMove, { passive: false });
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("wheel", onWheel);
document.removeEventListener("touchmove", onTouchMove);
};
}, [open]);
@@ -50,7 +77,16 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
useEffect(() => {
if (!open) return;
closeButtonRef.current?.focus();
// preventScroll: true — otherwise the browser's implicit
// scrollIntoView on focus() sees this button sitting close to the
// viewport's top edge and "corrects" for the global
// scroll-padding-top (reserved for the sticky Navbar, see
// globals.css) by nudging the whole page upward — pointless here
// since the modal is position:fixed and always fully in view
// regardless of document scroll, but that nudge is exactly the
// "page scrolls up a bit and the modal lands in a weird position"
// jank on open.
closeButtonRef.current?.focus({ preventScroll: true });
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
@@ -67,10 +103,10 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
last.focus({ preventScroll: true });
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
first.focus({ preventScroll: true });
}
};
+3 -3
View File
@@ -26,7 +26,7 @@ export function VersandModal({ open, onClose }: { open: boolean; onClose: () =>
useEffect(() => {
if (!open) return;
closeButtonRef.current?.focus();
closeButtonRef.current?.focus({ preventScroll: true });
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
@@ -43,10 +43,10 @@ export function VersandModal({ open, onClose }: { open: boolean; onClose: () =>
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
last.focus({ preventScroll: true });
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
first.focus({ preventScroll: true });
}
};
+6
View File
@@ -141,6 +141,12 @@
html {
scroll-padding-top: 6.25rem; /* navbar height */
/* Reserves the scrollbar's width permanently, so any modal's
overflow:hidden scroll lock (NewsletterModal, VersandModal) never
removes/re-adds the scrollbar itself — without this, losing the
scrollbar on open widens the viewport by its width and every fixed/sticky
element (Navbar included) visibly snaps sideways for one frame. */
scrollbar-gutter: stable;
}
body {
+12 -1
View File
@@ -119,18 +119,21 @@ export type LegalPage = {
type: LegalPageType;
title: string;
content: unknown;
attachment: { url: string; title: string } | null;
};
type PayloadLegalPage = {
type: LegalPageType;
title: string;
content: unknown;
attachment: { url: string; title: string } | number | null;
};
export async function getLegalPage(type: LegalPageType): Promise<LegalPage | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[type][equals]": type,
depth: "1",
limit: "1",
});
@@ -145,5 +148,13 @@ export async function getLegalPage(type: LegalPageType): Promise<LegalPage | nul
const data: { docs?: PayloadLegalPage[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return { type: doc.type, title: doc.title, content: doc.content };
return {
type: doc.type,
title: doc.title,
content: doc.content,
attachment:
typeof doc.attachment === "object" && doc.attachment
? { url: doc.attachment.url, title: doc.attachment.title }
: null,
};
}
+117
View File
@@ -0,0 +1,117 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
export const metadata: Metadata = {
title: "Widerrufsrecht",
description: "Widerrufsbelehrung und Muster-Widerrufsformular für einfach produktiv.",
alternates: { canonical: "/widerruf" },
};
export default async function WiderrufPage() {
const page = await getLegalPage("widerruf");
const headings = page ? extractHeadings(page.content) : [];
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
<span></span>
<span className="text-text-primary">Widerruf</span>
</p>
<p
className="font-semibold text-h-feature text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Widerrufsrecht
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
</Reveal>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
<SectionTOC sections={headings} />
{/* Static, not part of the CMS content — same reasoning as
the Impressum/Datenschutz/AGB pages' own callout cards. */}
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
<p
className="font-semibold text-body text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Nachhaltig handeln
</p>
<p className="text-body-sm text-text-muted">
Wir setzen auf digitale Produkte und vermeiden unnötige Ressourcenverschwendung.
</p>
<p className="text-body-sm text-text-muted">Für eine bessere Zukunft für uns alle.</p>
<div className="h-[0.125rem] w-8 bg-brand" />
</div>
</div>
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
{page ? (
<RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
{page?.attachment && (
<a
href={page.attachment.url}
target="_blank"
rel="noopener noreferrer"
download
className="group flex items-center gap-5 bg-bg-muted hover:bg-bg-white border border-border rounded-md p-6 transition-colors"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-sm bg-bg-white border border-border text-brand">
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path
d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path d="M15 2v5h5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path
d="M8.5 17.5v-5h1.4c.94 0 1.6.62 1.6 1.5s-.66 1.5-1.6 1.5H8.5m5.2 2v-5h1.2c1.16 0 1.9.9 1.9 2.5s-.74 2.5-1.9 2.5h-1.2Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="font-semibold text-body text-text-primary">
{page.attachment.title || "Muster-Widerrufsformular (PDF)"}
</p>
<p className="text-body-sm text-text-muted">Herunterladen</p>
</div>
<svg
viewBox="0 0 24 24"
className="size-5 shrink-0 text-text-muted group-hover:text-brand group-hover:translate-y-0.5 transition-all"
fill="none"
aria-hidden="true"
>
<path d="M12 4v13m0 0-5-5m5 5 5-5M5 21h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
)}
</div>
</div>
<TrustRow />
</main>
<Footer />
</>
);
}