"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: "Wöchentliche 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. */}

Kleine Impulse – große Wirkung

Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.

{/* Quick-access signup here, full pitch (Testimonials, "So funktioniert's") one click away for anyone not ready to type an email yet — same "lightweight modal vs. full page" split this file's own top comment describes, just also offering the other side of it from within the modal itself instead of only via the Navbar. */} Mehr über die wöchentlichen Impulse erfahren {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}

))}
)}
); }