diff --git a/app/api/newsletter/subscribe/route.ts b/app/api/newsletter/subscribe/route.ts index 3791b11..6c3e1e2 100644 --- a/app/api/newsletter/subscribe/route.ts +++ b/app/api/newsletter/subscribe/route.ts @@ -1,26 +1,27 @@ import { NextResponse } from "next/server"; -import { upsertNewsletterContact } from "../../../lib/brevo"; +import { upsertNewsletterContact, type NewsletterOptInSource } from "../../../lib/brevo"; +import { isValidEmail } from "../../../lib/email"; type SubscribeBody = { email?: string; consent?: boolean; - source?: "newsletter-page" | "newsletter-modal"; + source?: NewsletterOptInSource; }; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const VALID_SOURCES: NewsletterOptInSource[] = ["newsletter-page", "newsletter-modal", "newsletter-hero", "challenge"]; export async function POST(req: Request) { const body: SubscribeBody = await req.json(); const email = body.email?.trim() ?? ""; - if (!EMAIL_PATTERN.test(email)) { + if (!isValidEmail(email)) { return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 }); } if (!body.consent) { return NextResponse.json({ ok: false, reason: "Bitte akzeptiere die Datenschutzerklärung." }, { status: 400 }); } - const source = body.source === "newsletter-modal" ? "newsletter-modal" : "newsletter-page"; + const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page"; const result = await upsertNewsletterContact(email, source); if (!result.ok) { return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 }); diff --git a/app/challenge/components/EmailCapture.tsx b/app/challenge/components/EmailCapture.tsx new file mode 100644 index 0000000..a3322af --- /dev/null +++ b/app/challenge/components/EmailCapture.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; +import { useNewsletterSignup } from "../../lib/useNewsletterSignup"; + +function LockIcon() { + return ( + + + + + ); +} + +export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) { + const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } = + useNewsletterSignup("challenge"); + + if (status === "success") { + return

Danke! Du bist jetzt für den Newsletter angemeldet.

; + } + + return ( +
+
+ handleEmailChange(e.target.value)} + onBlur={(e) => handleEmailBlur(e.target.value)} + placeholder="Deine E-Mail-Adresse" + aria-invalid={Boolean(emailError)} + className={`flex-1 min-w-0 bg-white border rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none transition-colors ${ + emailError ? "border-red-600 focus:border-red-600" : "border-[#d9d9d9] focus:border-[#f6a701]" + }`} + /> + +
+ {emailError &&

{emailError}

} + {/* Consent checkbox — this signup's legal basis is consent (email + marketing), same wording as the other newsletter forms; colors + match this page's own hardcoded palette instead of the shared + design tokens, consistent with the rest of the page. */} + + {status === "error" &&

{error}

} +

+ + Keine Werbung. Jederzeit abbestellbar. +

+
+ ); +} diff --git a/app/challenge/page.tsx b/app/challenge/page.tsx index 1b9b5df..f99e6df 100644 --- a/app/challenge/page.tsx +++ b/app/challenge/page.tsx @@ -7,6 +7,7 @@ import { Reveal, RevealGroup, RevealItem } from "../components/Reveal"; import { TestimonialsGrid } from "../components/TestimonialsGrid"; import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid"; import { getTestimonials } from "../lib/payload"; +import { EmailCapture } from "./components/EmailCapture"; const title = "7-Tage-Challenge – Mehr Klarheit in 7 Tagen"; const description = @@ -82,15 +83,6 @@ function Check() { ); } -function LockIcon() { - return ( - - - - - ); -} - const steps = [ { icon: , @@ -122,52 +114,6 @@ const benefits = [ { title: "Gelassener leben", desc: "Weniger Stress, mehr Zeit für die Dinge, die dir wichtig sind." }, ]; -function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) { - return ( -
-
- - -
- {/* Consent checkbox — this signup's legal basis is consent (email - marketing), same wording as the other newsletter forms; colors - match this page's own hardcoded palette instead of the shared - design tokens, consistent with the rest of the page. */} - -

- - Keine Werbung. Jederzeit abbestellbar. -

-
- ); -} - export default async function ChallengePage() { const { isEnabled: isPreview } = await draftMode(); const testimonials = await getTestimonials("challenge", { draft: isPreview }); diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index f0e5db9..8baad76 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -19,6 +19,7 @@ import { dispatchAuthChanged } from "../../lib/auth"; import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft"; import { normalizeVatId, isValidVatId } from "../../lib/vatId"; import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption"; +import { validateEmailFormat } from "../../lib/email"; import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload"; import type { CustomerProfile } from "../../lib/customerAuth"; @@ -44,11 +45,6 @@ function validateRequired(label: string, value: string): string { return value.trim() ? "" : `${label} ist erforderlich.`; } -function validateEmailFormat(value: string): string { - if (!value.trim()) return "E-Mail-Adresse ist erforderlich."; - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "" : "Bitte eine gültige E-Mail-Adresse angeben."; -} - function validateZip(value: string, country: string, plzDigitsMap: Record): string { if (!value.trim()) return "PLZ ist erforderlich."; const digits = plzDigitsMap[country] ?? 4; diff --git a/app/components/Newsletter.tsx b/app/components/Newsletter.tsx index 7a22b9a..69810f3 100644 --- a/app/components/Newsletter.tsx +++ b/app/components/Newsletter.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, type FormEvent, type ReactNode } from "react"; +import type { ReactNode } from "react"; import Link from "next/link"; import Image from "next/image"; import { Reveal } from "./Reveal"; +import { useNewsletterSignup } from "../lib/useNewsletterSignup"; // Same lock icon + copy as /challenge's and /newsletter's EmailCapture — // unified across all newsletter-signup forms instead of each having its @@ -33,33 +34,8 @@ export function Newsletter({ title = <>Starte mit einer Woche voller Klarheit., description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.", }: NewsletterProps = {}) { - const [email, setEmail] = useState(""); - const [consent, setConsent] = useState(false); - const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle"); - const [error, setError] = useState(""); - - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - setStatus("submitting"); - setError(""); - try { - const res = await fetch("/api/newsletter/subscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, consent, source: "newsletter-page" }), - }); - const data = await res.json(); - if (!data.ok) { - setError(data.reason || "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut."); - setStatus("error"); - return; - } - setStatus("success"); - } catch { - setError("Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut."); - setStatus("error"); - } - } + const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } = + useNewsletterSignup("newsletter-page"); return (
@@ -112,12 +88,17 @@ export function Newsletter({ {/* Input + submit button — stacked below md, side by side from md+ */}
setEmail(e.target.value)} + onChange={(e) => handleEmailChange(e.target.value)} + onBlur={(e) => handleEmailBlur(e.target.value)} placeholder="Deine E-Mail-Adresse" - className="flex-1 min-w-0 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" + aria-invalid={Boolean(emailError)} + className={`flex-1 min-w-0 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 && ( +

{emailError}

+ )} {/* Consent checkbox — required since this signup's legal basis is consent (email marketing), not the "Ich achte diff --git a/app/components/NewsletterModal.tsx b/app/components/NewsletterModal.tsx index a9aa562..b2e067a 100644 --- a/app/components/NewsletterModal.tsx +++ b/app/components/NewsletterModal.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useRef, useState, type FormEvent } from "react"; +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 = [ { @@ -34,33 +35,8 @@ const features = [ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) { const dialogRef = useRef(null); const closeButtonRef = useRef(null); - const [email, setEmail] = useState(""); - const [consent, setConsent] = useState(false); - const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle"); - const [error, setError] = useState(""); - - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - setStatus("submitting"); - setError(""); - try { - const res = await fetch("/api/newsletter/subscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, consent, source: "newsletter-modal" }), - }); - const data = await res.json(); - if (!data.ok) { - setError(data.reason || "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut."); - setStatus("error"); - return; - } - setStatus("success"); - } catch { - setError("Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut."); - setStatus("error"); - } - } + 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 @@ -221,13 +197,21 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
setEmail(e.target.value)} + onChange={(e) => handleEmailChange(e.target.value)} + onBlur={(e) => handleEmailBlur(e.target.value)} 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" + 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 && ( +

{emailError}

+ )} -
+ {status === "success" ? ( +

+ Danke! Du bist jetzt für den Newsletter angemeldet. +

+ ) : ( + +
+ handleEmailChange(e.target.value)} + onBlur={(e) => handleEmailBlur(e.target.value)} + placeholder="Deine E-Mail-Adresse" + aria-invalid={Boolean(emailError)} + className={`w-full sm:w-[17.5rem] bg-bg-base border rounded-sm px-4 py-[0.8125rem] text-body-sm text-text-muted font-normal outline-none transition-colors ${ + emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand" + }`} + /> + +
+ {emailError && ( +

{emailError}

+ )} - {/* Consent checkbox — this signup's legal basis is consent - (email marketing), same wording/pattern as the shared - Newsletter component's and NewsletterModal's checkbox. */} - + {/* Consent checkbox — this signup's legal basis is consent + (email marketing), same wording/pattern as the shared + Newsletter component's and NewsletterModal's checkbox. */} + -
- - Keine Werbung. Jederzeit abbestellbar. -
+ {status === "error" && ( +

{error}

+ )} + +
+ + Keine Werbung. Jederzeit abbestellbar. +
+
+ )}