Files
einfach-produktiv/app/components/NewsletterModal.tsx
T
Marco 9e5532be63 Add shop, cart, versand pages and Impulse & Tipps detail page
- New /shop overview, /cart (real cart state via useSyncExternalStore),
  /versand (shipping policy page with TOC) and /newsletter detail page
- Cart: quantity/removal, order summary, related-products cross-sell
  with randomized picks, VersandModal quick-reference instead of
  navigating away, MwSt. disclosure next to unit prices
- Add-to-cart UX: inline success feedback (green state) plus a
  fly-to-navbar-cart-icon animation (CartFlyProvider) with a delayed
  badge count-up; AddToCartButton no longer navigates straight to /cart
- Shared lib/products.ts catalog and lib/shipping.ts constants (cost,
  free-shipping threshold, handling/transit days) so cart, trust badges
  and the versand page can never drift apart
- Fix Navbar smooth-scroll easing (ease-out instead of ease-in-out, no
  more perceived start delay); compress newsletter modal photo
  2.4MB -> 85KB to fix first-open jank
2026-07-19 14:42:22 +00:00

212 lines
8.6 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import Image from "next/image";
import { AnimatePresence, motion } from "motion/react";
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);
// Body scroll lock while open.
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prevOverflow;
};
}, [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;
closeButtonRef.current?.focus();
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();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
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"
>
<img alt="" src="/icon-close.png" 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. */}
<div className="w-16 h-14 -rotate-4 -scale-y-100">
<img alt="" src="/newsletter-icon.svg" 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
</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>
<form className="flex flex-col gap-5 items-start w-full">
<div className="flex flex-col gap-4 items-start w-full">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="w-full bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
/>
<button
type="submit"
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"
>
Jetzt anmelden
</button>
</div>
<label className="flex gap-2 items-center w-full cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary">
Ich akzeptiere die Datenschutzerklärung.
</span>
</label>
</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="h-10 w-10 shrink-0 flex items-center justify-center">
<img alt="" src={f.icon} className="max-h-10 max-w-10 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>
);
}