"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, handleConsentChange, 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(); }} > {/* sticky, not absolute — the dialog itself is the scrolling container (overflow-y-auto above), so an absolute-positioned child scrolls away with the rest of the content instead of staying pinned to the visible top-right corner (reported 2026-07-29). sticky top-6 keeps it fixed to the scrolled viewport's top edge; ml-auto pushes it to the right within the dialog's normal block flow (sticky positioning doesn't use right-* the way absolute does); -mb-6 cancels its own height (size-6 = 1.5rem) so it doesn't push the modal-top content below it down — same visual overlap as the old absolute positioning, just still visible after scrolling. */} {/* modal-top: photo + copy/form, stacked below lg: — paired with the dialog's own narrower max-w-[36rem] cap through Tablet (see above), so the stacked photo stays a reasonably-sized 4:3 banner instead of blowing up to near-full-viewport-width. */}
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 lg: — removed on mobile 2026-07-24. */}

Starte mit einer Woche voller Klarheit.

Melde dich zum Newsletter an und bekommst 7 kurze Impulse direkt ins Postfach – in ein paar Minuten gelesen, sofort im Alltag umsetzbar.

{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" }`} /> {/* Always rendered (min-h reserves one line's worth of space) rather than conditionally mounted — this sits inside the same row the photo on the left stretches to match (items-stretch, md:aspect-auto), so an error popping in and out used to grow/shrink the whole modal, visibly resizing the photo along with it. */}

{emailError}

{/* Same reserved-space fix as emailError above — this is the "already subscribed" message, the one that actually prompted it. */}

{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. */} {/* Same lg: exception as modal-top above, for the same narrower- dialog-width-through-Tablet reason. */}
{features.map((f) => (

{f.title}

{f.desc}

))}
)}
); }