Files
2026-07-29 20:52:42 +00:00

316 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(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<HTMLElement>(
'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.
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-[60] flex items-center justify-center p-4 md:p-8 bg-[rgba(134,134,134,0.9)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="newsletter-modal-heading"
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
// Capped narrower than the Desktop 75rem through the whole
// 640-1023px Tablet band — this dialog is a modal, not a page
// section, so at Tablet it should read as a compact centered
// card, not stretch to near-full-viewport-width once stacked
// single-column (see the flex-col/flex-row split below):
// full-bleed-width + a single stacked photo/text column made
// the photo huge and the text below it look lost/disconnected
// (reported 2026-07-29, after first trying a lg:-gated
// structural stack with no width cap). max-w-[75rem] only
// takes over once the 2-column split itself starts at lg:.
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[36rem] lg:max-w-[75rem] max-h-[90vh] overflow-y-auto"
>
{/* 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. */}
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
aria-label="Schließen"
className="sticky top-6 ml-auto mr-6 -mb-6 z-20 size-6 flex items-center justify-center active:scale-90 transition-transform"
>
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
</button>
{/* 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. */}
<div className="flex flex-col lg:flex-row items-stretch border-b border-border">
<div className="relative w-full lg:flex-1 aspect-[4/3] lg:aspect-auto">
<Image
src="/newsletter-modal-photo.jpg"
alt="Notizbuch mit Kaffee und Stift"
fill
sizes="(min-width: 1024px) 50vw, 36rem"
className="object-cover"
/>
</div>
<div className="flex flex-col gap-6 items-start justify-center flex-1 min-w-0 px-8 py-10 md:px-[4.6875rem] md:py-[6.25rem]">
{/* -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. */}
<div className="hidden lg:block w-16 h-14 -rotate-4 -scale-y-100">
<Image alt="" src="/newsletter-icon.svg" width={64} height={56} className="w-full h-full" />
</div>
<p
id="newsletter-modal-heading"
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-lora)" }}
>
Kleine Impulse große Wirkung
</p>
<p className="text-body text-text-primary">
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.
</p>
{status === "success" ? (
<p className="text-body text-text-primary font-medium">{successMessage}</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
<div className="flex flex-col gap-2 items-start w-full">
<input
ref={emailRef}
type="email"
required
value={email}
onChange={(e) => 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. */}
<p className="text-label text-red-600 font-normal -mt-2 min-h-[1.05rem]">{emailError}</p>
<button
type="submit"
disabled={status === "submitting"}
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base disabled:opacity-60 disabled:pointer-events-none"
>
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
</button>
</div>
<label className="flex gap-2 items-center w-full cursor-pointer">
<input
type="checkbox"
required
checked={consent}
onChange={(e) => handleConsentChange(e.target.checked)}
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
{/* Same reserved-space fix as emailError above — this is
the "already subscribed" message, the one that actually
prompted it. */}
<p className="text-label text-red-600 font-normal min-h-[1.05rem]">
{status === "error" ? error : ""}
</p>
</form>
)}
</div>
</div>
{/* 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. */}
<div className="flex flex-col lg:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 lg:gap-6">
{features.map((f) => (
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
<Image alt="" src={f.icon} fill sizes="40px" className="object-contain" />
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0">
<p
className="font-semibold text-h-small text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{f.title}
</p>
<p className="text-body text-text-primary w-full">{f.desc}</p>
</div>
</div>
))}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}