b835e8622f
NewsletterModal.tsx advertised a different offer (the 7-Tage-Challenge) than Newsletter.tsx's own description (7 Impulse) for the same signup flow. Also rewrote both descriptions to drop the vague "und gewinne Klarheit" buzzword formula and the word "Klarheit" repeated from the heading directly above it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
"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<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] }}
|
||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||
>
|
||
<button
|
||
ref={closeButtonRef}
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Schließen"
|
||
className="absolute top-6 right-6 z-10 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 md */}
|
||
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
|
||
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
|
||
<Image
|
||
src="/newsletter-modal-photo.jpg"
|
||
alt="Notizbuch mit Kaffee und Stift"
|
||
fill
|
||
sizes="(min-width: 768px) 50vw, 100vw"
|
||
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 md: — removed on mobile 2026-07-24. */}
|
||
<div className="hidden md: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)" }}
|
||
>
|
||
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
|
||
</p>
|
||
|
||
<p className="text-body text-text-primary">
|
||
Melde dich zum Newsletter an und bekommst 7 kurze Impulse direkt ins Postfach – in ein paar Minuten gelesen, sofort im Alltag umsetzbar.
|
||
</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-4 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. */}
|
||
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md: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>
|
||
);
|
||
}
|