"use client"; import { useEffect, useRef } from "react"; import Image from "next/image"; import Link from "next/link"; import { AnimatePresence, motion } from "motion/react"; import { useNewsletterSignup } from "../lib/useNewsletterSignup"; const features = [ { icon: "/icon-sparkle-wrapper.svg", title: "7 Tage. Ein Fokus.", desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.", }, { icon: "/icon-checklist.png", title: "Praktisch & umsetzbar.", desc: "Direkt anwendbare Methoden für deinen Alltag.", }, { icon: "/icon-heart.png", title: "Kein Spam. Versprochen.", desc: "Nur wertvolle Inhalte, wenn du sie brauchst.", }, ]; /** * The Navbar's "Newsletter" CTA opens this modal rather than navigating — * matches the original Figma prototype (Newsletter-Button → Overlay * newsletter-overlay), unlike the Werkzeuge "Impulse & Tipps" card's * "Anmelden" link, which goes to the full /newsletter detail page instead. * Different entry points, different weight: a quick-access nav CTA gets a * lightweight in-place signup, a content card gets the full page. */ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) { const dialogRef = useRef(null); const closeButtonRef = useRef(null); const { email, emailError, consent, setConsent, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } = useNewsletterSignup("newsletter-modal"); // 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 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.removeEventListener("wheel", onWheel); document.removeEventListener("touchmove", onTouchMove); }; }, [open]); // Focus management: move focus into the modal on open, trap Tab within // it, close + return focus on Escape — same pattern as the Navbar's // mobile drawer. useEffect(() => { if (!open) return; // 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") { e.preventDefault(); onClose(); return; } if (e.key !== "Tab") return; const focusables = dialogRef.current?.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled])' ); if (!focusables || focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus({ preventScroll: true }); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus({ preventScroll: true }); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [open, onClose]); return ( // AnimatePresence, not a plain `if (!open) return null` — that would // unmount the modal instantly on close with no way to play an exit // animation first. Keeping it mounted (conditionally rendering the // child) lets Framer Motion finish the fade-out before removing it. {open && ( { if (e.target === e.currentTarget) onClose(); }} > {/* modal-top: photo + copy/form, stacked below md */}
Notizbuch mit Kaffee und Stift
{/* -scale-y-100 is required, not just -rotate-4 — the SVG itself is authored upside-down (matches how Newsletter.tsx uses this exact same asset); without it the icon renders flipped. Hidden below md: — removed on mobile 2026-07-24. */}

Starte mit einer Woche voller Klarheit.

Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.

{status === "success" ? (

{successMessage}

) : (
handleEmailChange(e.target.value)} onBlur={(e) => handleEmailBlur(e.target.value)} placeholder="Deine E-Mail-Adresse" aria-invalid={Boolean(emailError)} className={`w-full bg-bg-white border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none transition-colors ${ emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand" }`} /> {emailError && (

{emailError}

)}
{status === "error" && (

{error}

)}
)}
{/* modal-bottom: 3 feature cards. Figma's export had items-end on this row, but that's a byproduct of nested -scale-y-100 flip-wrappers Figma uses for baseline-grid tricks (visually cancels out to top-alignment in the actual design) — taken literally it bottom-aligned the icon with the last line of description text instead of the title, which read as broken. items-start + a fixed icon bounding box (icons have different native proportions, e.g. the sparkle glyph isn't square) fixes it without needing the flip trick. */}
{features.map((f) => (

{f.title}

{f.desc}

))}
)}
); }