d389790c57
TrustRow now uses flex-wrap + justify-center so badges flow as many per row as actually fit at the current width, wrapping (and auto-centering, a native flexbox behavior for the last wrapped line) instead of needing a breakpoint or risking overflow. Replaces the earlier lg:-only structural exception entirely. NewsletterModal's close button was absolutely positioned inside the dialog's own scrolling container, so it scrolled away with the content. Switched to sticky (top-6, ml-auto for horizzontal position, -mb-6 to cancel its own height so it doesn't push content down) so it stays pinned to the top-right corner while scrolling.
316 lines
15 KiB
TypeScript
316 lines
15 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] }}
|
||
// 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)" }}
|
||
>
|
||
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. */}
|
||
{/* 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>
|
||
);
|
||
}
|