From 6a4539bf9b3149da7880d8cfa74fff11a53b31a6 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 20:38:41 +0000 Subject: [PATCH] Sync newsletter opt-ins to Brevo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both standalone signup forms (Newsletter.tsx on Home/newsletter page, NewsletterModal.tsx from the Navbar CTA) were previously non-functional — static markup with no onSubmit/state at all, nothing was ever captured. They're now real client forms posting to the new /api/newsletter/subscribe route, which upserts the contact into Brevo's Contacts API (list id from BREVO_LIST_ID). Checkout's existing newsletterOptIn checkbox gets the same sync, fire-and-forget alongside the order-confirmation email — a failed marketing sync must never fail checkout. lib/brevo.ts is the only thing that talks to Brevo; this app still never sends marketing mail itself. Whatever automation Brevo has configured on the list (Welcome Flow etc.) runs entirely on their side — Brevo's Automation workflows aren't manageable via their public API at all, so that part can't be wired up from here. Needs BREVO_API_KEY and BREVO_LIST_ID set in the frontend's Coolify environment — not yet added there. Co-Authored-By: Claude Sonnet 5 --- app/api/checkout/route.ts | 8 ++ app/api/newsletter/subscribe/route.ts | 29 ++++++ app/components/Newsletter.tsx | 140 +++++++++++++++++--------- app/components/NewsletterModal.tsx | 109 ++++++++++++++------ app/lib/brevo.ts | 48 +++++++++ 5 files changed, 254 insertions(+), 80 deletions(-) create mode 100644 app/api/newsletter/subscribe/route.ts create mode 100644 app/lib/brevo.ts diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index ee61c02..ec03c62 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -11,6 +11,7 @@ import { sendOrderConfirmationEmail } from "../../lib/orderEmail"; import { normalizeVatId, isValidVatId } from "../../lib/vatId"; import { checkVatIdViaVies } from "../../lib/vies"; import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption"; +import { upsertNewsletterContact } from "../../lib/brevo"; // Plain float arithmetic on money (quantity × unitPrice summed across // lines, a percent discount, subtracting/adding those together) drifts @@ -376,6 +377,13 @@ export async function POST(request: Request) { }); }); + // Fire-and-forget, same reasoning as the confirmation email above — a + // failed marketing sync is not worth failing checkout over, and doesn't + // even need a critical alert (nothing customer-facing depends on it). + if (body.newsletterOptIn) { + upsertNewsletterContact(body.email, "checkout").catch(() => {}); + } + return NextResponse.json({ ok: true, orderNumber: order.orderNumber, diff --git a/app/api/newsletter/subscribe/route.ts b/app/api/newsletter/subscribe/route.ts new file mode 100644 index 0000000..3791b11 --- /dev/null +++ b/app/api/newsletter/subscribe/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { upsertNewsletterContact } from "../../../lib/brevo"; + +type SubscribeBody = { + email?: string; + consent?: boolean; + source?: "newsletter-page" | "newsletter-modal"; +}; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export async function POST(req: Request) { + const body: SubscribeBody = await req.json(); + const email = body.email?.trim() ?? ""; + + if (!EMAIL_PATTERN.test(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 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 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/app/components/Newsletter.tsx b/app/components/Newsletter.tsx index 670fc9e..7a22b9a 100644 --- a/app/components/Newsletter.tsx +++ b/app/components/Newsletter.tsx @@ -1,4 +1,6 @@ -import type { ReactNode } from "react"; +"use client"; + +import { useState, type FormEvent, type ReactNode } from "react"; import Link from "next/link"; import Image from "next/image"; import { Reveal } from "./Reveal"; @@ -31,6 +33,34 @@ 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"); + } + } + return (
@@ -72,54 +102,70 @@ export function Newsletter({ {/* Right: form — takes remaining space, centered vertically from md+ */}
-
- - {/* Input + submit button — stacked below md, side by side from md+ */} -
- - -
- - {/* Consent checkbox — required since this signup's legal - basis is consent (email marketing), not the "Ich achte - auf deine Daten" trust note alone. Same wording/pattern - as NewsletterModal's checkbox. */} - - - {/* Privacy note — same icon/copy/color as the other - newsletter forms (see /challenge's EmailCapture). */} -

- - Keine Werbung. Jederzeit abbestellbar. + {status === "success" ? ( +

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

+ ) : ( +
-
+ {/* Input + submit button — stacked below md, side by side from md+ */} +
+ setEmail(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" + /> + +
+ + {/* Consent checkbox — required since this signup's legal + basis is consent (email marketing), not the "Ich achte + auf deine Daten" trust note alone. Same wording/pattern + as NewsletterModal's checkbox. */} + + + {status === "error" && ( +

{error}

+ )} + + {/* Privacy note — same icon/copy/color as the other + newsletter forms (see /challenge's EmailCapture). */} +

+ + Keine Werbung. Jederzeit abbestellbar. +

+ + )}
diff --git a/app/components/NewsletterModal.tsx b/app/components/NewsletterModal.tsx index 107c0e0..a9aa562 100644 --- a/app/components/NewsletterModal.tsx +++ b/app/components/NewsletterModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import Image from "next/image"; import Link from "next/link"; import { AnimatePresence, motion } from "motion/react"; @@ -34,6 +34,33 @@ 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"); + } + } // Background scroll lock while open — intercepts and cancels the wheel/ // touch input that would cause scrolling, instead of toggling @@ -186,39 +213,55 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.

-
-
- - -
-