Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m11s
- Kopfzeile: aktive Seite mit Honig-Unterstrich (auch im Handy-Menue), einlaufende Unterstriche beim Hover, beim Scrollen Glas und Schatten; weicht beim Runterscrollen aus und ist beim Hochscrollen sofort zurueck (steht still, solange Warenkorb oder Menue offen sind) - Fusszeile mit Wabenraster und einlaufenden Link-Unterstrichen - Mengensteller: gezeichnete Plus/Minus, taktiles Druecken, tickende Zahl - Newsletter als eigene Flaeche mit Wabenraster; Erfolg zeichnet ein Haekchen in eine Wabe, Fehler werden als Alert angesagt - 404: Wabe mit einer fehlenden Zelle als Bild fuer die fehlende Seite Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, type FormEvent } from "react";
|
|
import { motion } from "motion/react";
|
|
import { HoneycombPattern } from "@/components/honeycomb";
|
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
|
import { TurnstileWidget } from "@/components/turnstile-widget";
|
|
import { useI18n } from "@/i18n/i18n-provider";
|
|
|
|
type Status = "idle" | "loading" | "success" | "error";
|
|
|
|
/**
|
|
* Der Turnstile-Sitekey kommt als Prop vom Server und NICHT aus
|
|
* process.env in dieser Datei.
|
|
*
|
|
* Grund: NEXT_PUBLIC_-Variablen werden in Client Components zur BUILD-Zeit
|
|
* ins Bundle eingebacken. Der Wert wird hier aber erst zur Laufzeit gesetzt
|
|
* (k8s/01-configmap.yaml). Ein Zugriff auf process.env an dieser Stelle
|
|
* ergibt deshalb dauerhaft undefined — das Widget erschiene nie, waehrend die
|
|
* Serverseite bei gesetztem TURNSTILE_SECRET_KEY sehr wohl einen Token
|
|
* verlangt. Das Formular waere damit unabsendbar, ohne dass irgendwo ein
|
|
* Fehler auftaucht.
|
|
*
|
|
* Alle uebrigen Formulare (Login, Registrierung, Passwort-Reset, Zwei-Faktor,
|
|
* Checkout, Verfuegbarkeits-Benachrichtigung) lesen den Wert bereits im
|
|
* Server Component und reichen ihn als Prop weiter. Diese Datei folgt jetzt
|
|
* demselben Muster.
|
|
*/
|
|
export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: string }) {
|
|
const { t } = useI18n();
|
|
const [email, setEmail] = useState("");
|
|
const [status, setStatus] = useState<Status>("idle");
|
|
const [turnstileToken, setTurnstileToken] = useState("");
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setStatus("loading");
|
|
try {
|
|
const response = await fetch("/api/newsletter", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, turnstileToken }),
|
|
});
|
|
if (!response.ok) throw new Error("request failed");
|
|
setStatus("success");
|
|
setEmail("");
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="py-24">
|
|
<Container className="max-w-4xl">
|
|
<ScrollReveal>
|
|
{/* Eigene Flaeche mit Wabenraster: die Anmeldung ist ein Angebot,
|
|
kein Nachsatz unter der Seite. */}
|
|
<div className="relative overflow-hidden border border-honey/30 bg-honey/[0.06] px-6 py-14 text-center sm:px-14">
|
|
<HoneycombPattern className="text-honey/25" size={24} />
|
|
<div className="relative mx-auto max-w-2xl">
|
|
<SectionHeading
|
|
eyebrow={t.home.newsletter.eyebrow}
|
|
title={t.home.newsletter.title}
|
|
align="center"
|
|
className="mx-auto"
|
|
/>
|
|
<p className="mt-4 text-ink/70">{t.home.newsletter.description}</p>
|
|
|
|
{status === "success" ? (
|
|
// Bestaetigung als kleine Zeremonie: das Haekchen zeichnet sich
|
|
// in der Wabe, dann erscheint der Text.
|
|
<div className="mt-8 flex flex-col items-center" role="status">
|
|
<svg viewBox="0 0 24 26" className="h-14 w-14" fill="none" aria-hidden="true">
|
|
<motion.polygon
|
|
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
|
fill="var(--color-honey)"
|
|
initial={{ scale: 0.4, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
transition={{ type: "spring", stiffness: 300, damping: 16 }}
|
|
style={{ transformOrigin: "center" }}
|
|
/>
|
|
<motion.path
|
|
d="M7.5 13.2l3 3 6-6.4"
|
|
stroke="var(--color-cream)"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
initial={{ pathLength: 0 }}
|
|
animate={{ pathLength: 1 }}
|
|
transition={{ duration: 0.4, delay: 0.25 }}
|
|
/>
|
|
</svg>
|
|
<motion.p
|
|
className="mt-4 font-serif text-2xl text-forest"
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.45 }}
|
|
>
|
|
{t.home.newsletter.successMessage}
|
|
</motion.p>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSubmit} className="mt-8 max-w-lg mx-auto">
|
|
{/* Eingabefeld und Knopf bilden die Zeile, das CAPTCHA steht
|
|
darunter. Frueher lagen alle drei in derselben Flex-Zeile
|
|
ohne flex-wrap: sobald das Widget tatsaechlich rendert,
|
|
belegte es rund 300 px der 512 px breiten Zeile und drueckte
|
|
das Eingabefeld auf 34 px zusammen. Solange kein Sitekey
|
|
gesetzt war, fiel das nicht auf, weil das Widget gar nicht
|
|
erst erschien. */}
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<input
|
|
type="email"
|
|
required
|
|
placeholder={t.home.newsletter.emailPlaceholder}
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="min-w-0 flex-1 border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
|
/>
|
|
<Button type="submit" disabled={status === "loading"} className="flex-shrink-0 whitespace-nowrap">
|
|
{status === "loading" ? t.home.newsletter.subscribing : t.home.newsletter.subscribe}
|
|
</Button>
|
|
</div>
|
|
{turnstileSiteKey ? (
|
|
<div className="mt-3 flex justify-center">
|
|
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
|
</div>
|
|
) : null}
|
|
</form>
|
|
)}
|
|
{status === "error" ? (
|
|
<p className="mt-4 text-sm text-red-700" role="alert">
|
|
{t.home.newsletter.errorMessage}
|
|
</p>
|
|
) : null}
|
|
|
|
<p className="mt-6 text-xs text-ink/65">
|
|
{t.home.newsletter.consentBeforeLink}
|
|
<a href="/datenschutz" className="underline">
|
|
{t.home.newsletter.privacyLinkLabel}
|
|
</a>
|
|
{t.home.newsletter.consentAfterLink}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</ScrollReveal>
|
|
</Container>
|
|
</section>
|
|
);
|
|
}
|