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
+6 -5
View File
@@ -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 });
+80
View File
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { useNewsletterSignup } from "../../lib/useNewsletterSignup";
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
</svg>
);
}
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 <p className="text-[1rem] text-[#222221] font-medium">Danke! Du bist jetzt für den Newsletter angemeldet.</p>;
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full">
<div className="flex gap-3 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={`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]"
}`}
/>
<button
type="submit"
disabled={status === "submitting"}
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2 disabled:opacity-60 disabled:pointer-events-none"
>
{status === "submitting" ? "Wird gesendet…" : buttonLabel}
</button>
</div>
{emailError && <p className="text-[0.8rem] text-red-600">{emailError}</p>}
{/* 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. */}
<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-[#d9d9d9] accent-[#f6a701]"
/>
<span className="text-[0.8rem] text-[#444] leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-[#f6a701]"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
{status === "error" && <p className="text-[0.8rem] text-red-600">{error}</p>}
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
<LockIcon />
Keine Werbung. Jederzeit abbestellbar.
</p>
</form>
);
}
+1 -55
View File
@@ -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 (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
</svg>
);
}
const steps = [
{
icon: <IconEnvelope />,
@@ -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 (
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-3 w-full">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="flex-1 min-w-0 bg-white border border-[#d9d9d9] rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none focus:border-[#f6a701] transition-colors"
/>
<button
type="submit"
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2"
>
{buttonLabel}
</button>
</div>
{/* 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. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
/>
<span className="text-[0.8rem] text-[#444] leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-[#f6a701]"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
<LockIcon />
Keine Werbung. Jederzeit abbestellbar.
</p>
</div>
);
}
export default async function ChallengePage() {
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("challenge", { draft: isPreview });
+1 -5
View File
@@ -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, number>): string {
if (!value.trim()) return "PLZ ist erforderlich.";
const digits = plzDigitsMap[country] ?? 4;
+14 -30
View File
@@ -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<span className="text-brand">.</span></>,
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<HTMLFormElement>) {
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 (
<section className="py-16 w-full">
@@ -112,12 +88,17 @@ export function Newsletter({
{/* Input + submit button — stacked below md, side by side from md+ */}
<div className="flex flex-col md:flex-row gap-4 items-stretch w-full">
<input
ref={emailRef}
type="email"
required
value={email}
onChange={(e) => 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"
}`}
/>
<button
type="submit"
@@ -127,6 +108,9 @@ export function Newsletter({
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
</button>
</div>
{emailError && (
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
)}
{/* Consent checkbox — required since this signup's legal
basis is consent (email marketing), not the "Ich achte
+14 -30
View File
@@ -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<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(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<HTMLFormElement>) {
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: ()
<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) => 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 && (
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
)}
<button
type="submit"
disabled={status === "submitting"}
+3 -1
View File
@@ -8,12 +8,14 @@ const BREVO_API_URL = "https://api.brevo.com/v3/contacts";
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
export type NewsletterOptInSource = "checkout" | "newsletter-page" | "newsletter-modal" | "newsletter-hero" | "challenge";
// `source` becomes a Brevo contact attribute so campaigns/segments can
// tell a checkout opt-in apart from the standalone signup forms without
// needing separate lists.
export async function upsertNewsletterContact(
email: string,
source: "checkout" | "newsletter-page" | "newsletter-modal",
source: NewsletterOptInSource,
): Promise<BrevoSyncResult> {
const apiKey = process.env.BREVO_API_KEY;
const listId = process.env.BREVO_LIST_ID;
+15
View File
@@ -0,0 +1,15 @@
// Single source of truth for "is this a plausible email address" — used
// client-side (checkout, newsletter forms) for immediate on-blur feedback
// and server-side (newsletter subscribe route) as the same check, not a
// second one that could drift out of sync.
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(value: string): boolean {
return EMAIL_PATTERN.test(value);
}
// Returns "" for valid, an error message otherwise.
export function validateEmailFormat(value: string): string {
if (!value.trim()) return "E-Mail-Adresse ist erforderlich.";
return isValidEmail(value) ? "" : "Bitte eine gültige E-Mail-Adresse angeben.";
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
import { validateEmailFormat } from "./email";
import type { NewsletterOptInSource } from "./brevo";
// Shared state/submit logic behind every newsletter-signup form
// (Newsletter.tsx, NewsletterModal.tsx, WeeklyImpulsesHero.tsx's inline
// hero form, /challenge's EmailCapture) — four places with the same
// email+consent+submit shape but different markup/visual style, so only
// the logic is shared here rather than a one-size-fits-all component.
export function useNewsletterSignup(source: NewsletterOptInSource) {
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [consent, setConsent] = useState(false);
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [error, setError] = useState("");
const emailRef = useRef<HTMLInputElement>(null);
function handleEmailChange(value: string) {
setEmail(value);
if (emailError) setEmailError("");
}
function handleEmailBlur(value: string) {
setEmailError(validateEmailFormat(value));
}
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const formatError = validateEmailFormat(email);
setEmailError(formatError);
if (formatError) {
emailRef.current?.focus();
return;
}
setStatus("submitting");
setError("");
try {
const res = await fetch("/api/newsletter/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, consent, source }),
});
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 { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
}
@@ -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>