Files
einfach-produktiv/app/components/NewsletterModal.tsx
T
Marco bca29ab7a3 Fix more mobile responsive issues: Hero, arrows, cart button, checkmarks, modal
- Newsletter card icon: fixed an aspect-ratio distortion bug (w-16 maps
  to this project's fluid --spacing-16, which floors to 40px below
  768px, paired with a fixed 55px height — squished the icon on
  mobile). Same root cause existed in NewsletterModal's envelope icon;
  that one's now just hidden below md: instead per feedback.
- Hero: subtitle sized down and CTA checkmark icon scaled to match
  below lg:, social proof text always wraps below the avatars there
  instead of only when it doesn't fit.
- Werkzeuge connector arrows (todo-cards/newsletter/challenge
  HowItWorks): object-contain added — the SVG has
  preserveAspectRatio="none" and was stretching to fill the square
  mobile box instead of keeping its thin-arrow shape.
- Challenge bottom CTA: icon now stacks above the copy, centered, below
  lg: to match the Home Newsletter card's pattern.
- Homepage Werkzeuge icons: smaller below md: to match the (fluid-floor)
  text size next to them.
- Blog detail "Passend dazu" card: the fixed w-[19rem] title column plus
  "Entdecken" sharing its row overflowed on mobile; stacked below sm:
  instead, with a little top margin on "Entdecken".
- Todo-Karten checklist checkmarks: recolored brand-orange (icon-check
  .svg's fill lives in an internal CSS var that can't be overridden from
  outside an <img>-loaded SVG, so switched to an inline SVG) and
  top-aligned instead of vertically centered.
- AddToCartButton: added whitespace-nowrap to the stacked-label grid
  (used to reserve button width across all possible label texts) — one
  of those labels wrapping to two lines on a narrow w-full button was
  inflating the row height for whichever label is actually showing,
  leaving a tall empty gap under "Ausverkauft".
2026-07-24 19:01:18 +00:00

285 lines
13 KiB
TypeScript
Raw 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: "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, setConsent, status, error, 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 erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
</p>
{status === "success" ? (
<p className="text-body text-text-primary font-medium">
Fast geschafft! Schau kurz in dein Postfach da wartet schon eine Mail von uns.
</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"
}`}
/>
{emailError && (
<p className="text-label text-red-600 font-normal -mt-2">{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) => setConsent(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>
{status === "error" && (
<p className="text-label text-red-600 font-normal">{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>
);
}