Add on-blur email validation to every newsletter signup form

Extends the checkout pattern (inline red error text, refocus on
submit if invalid) to all four newsletter-signup entry points. Two of
them (WeeklyImpulsesHero's inline hero form on /newsletter, and
/challenge's EmailCapture) turned out to be completely non-functional
before this too — same static-markup-with-no-onSubmit issue as
Newsletter.tsx/NewsletterModal.tsx had, just missed in the previous
pass since they're separate components sharing only the visual
pattern, not the code.

Consolidated the shared email+consent+submit state (previously
duplicated per-component) into useNewsletterSignup.ts, and pulled the
plain email-format regex (previously duplicated in CheckoutContent.tsx
and the subscribe route) into lib/email.ts as a single source of
truth. /challenge's EmailCapture is now its own client component
(app/challenge/components/EmailCapture.tsx) since its parent page is
an async Server Component and can't hold form state itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 20:48:13 +00:00
parent 6a4539bf9b
commit 789a818c6b
10 changed files with 264 additions and 164 deletions
@@ -1,6 +1,9 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
import { useNewsletterSignup } from "../../lib/useNewsletterSignup";
// Same lock icon + copy as /challenge's and the shared Newsletter
// component's trust note — unified across all newsletter-signup forms.
@@ -21,6 +24,9 @@ const checklist = [
];
export function WeeklyImpulsesHero() {
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
useNewsletterSignup("newsletter-hero");
return (
<section className="bg-bg-base w-full overflow-hidden">
{/* Same lg:-only structural exception as Home/todo-cards Hero (see
@@ -93,46 +99,73 @@ export function WeeklyImpulsesHero() {
{/* Inline email capture — page-specific, simpler than the shared
Newsletter component's panel form (no button-adjacent styling
needed here, just input + submit inline). */}
<div className="flex gap-3 items-start w-full sm:w-auto">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="w-full sm:w-[17.5rem] bg-bg-base border border-border rounded-sm px-4 py-[0.8125rem] text-body-sm text-text-muted font-normal outline-none focus:border-brand transition-colors"
/>
<button
type="submit"
className="shrink-0 bg-brand rounded-sm px-6 py-[0.8125rem] font-bold text-body text-text-primary whitespace-nowrap 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>
{status === "success" ? (
<p className="text-body text-text-primary font-medium">
Danke! Du bist jetzt für den Newsletter angemeldet.
</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 items-start w-full">
<div className="flex gap-3 items-start w-full sm:w-auto">
<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 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"
}`}
/>
<button
type="submit"
disabled={status === "submitting"}
className="shrink-0 bg-brand rounded-sm px-6 py-[0.8125rem] font-bold text-body text-text-primary whitespace-nowrap 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>
{emailError && (
<p className="text-label text-red-600 font-normal">{emailError}</p>
)}
{/* Consent checkbox — this signup's legal basis is consent
(email marketing), same wording/pattern as the shared
Newsletter component's and NewsletterModal's checkbox. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
{/* Consent checkbox — this signup's legal basis is consent
(email marketing), same wording/pattern as the shared
Newsletter component's and NewsletterModal's checkbox. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
required
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
<div className="flex gap-[0.375rem] items-center">
<LockIcon />
<span className="text-label text-[#888]">Keine Werbung. Jederzeit abbestellbar.</span>
</div>
{status === "error" && (
<p className="text-label text-red-600 font-normal">{error}</p>
)}
<div className="flex gap-[0.375rem] items-center">
<LockIcon />
<span className="text-label text-[#888]">Keine Werbung. Jederzeit abbestellbar.</span>
</div>
</form>
)}
</div>
</Reveal>