feat: Kontaktformular für ein Erstgespräch
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s
Neuer Abschnitt nach den Fragen, im Menü als Erstgespräch. Anfrage geht als E-Mail an die Veranstalter-Adresse (Admin) bzw. ADMIN_NOTIFY_EMAIL, sofort zugestellt, sonst über den Worker wiederholt. Gleicher Schutz wie die Buchung: nur JSON von der eigenen Seite, Rate Limit (5/h je IP), Prüfung auf dem Server, Honeypot gegen Bots, doppeltes Absenden ergibt eine Mail. Keine automatische Mail an die angegebene Adresse (sonst Missbrauch für Spam an Dritte). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d45700a9ef
commit
7745d5862d
9 files changed
+336
-1
No files matched your search
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
|
||||
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "error"; message: string };
|
||||
type Fields = Record<string, string>;
|
||||
|
||||
const FIELD_IDS = ["name", "email", "phone", "availability", "message", "privacy"] as const;
|
||||
|
||||
/**
|
||||
* Anfrage fuer ein unverbindliches Erstgespraech (POST /api/contact). Die
|
||||
* Pruefung passiert auf dem Server; Fehler erscheinen am jeweiligen Feld,
|
||||
* der Fokus springt auf das erste fehlerhafte. Das Feld "website" ist eine
|
||||
* fuer Menschen unsichtbare Falle fuer Bots.
|
||||
*/
|
||||
export function ContactSection() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [status, setStatus] = useState<Status>({ kind: "idle" });
|
||||
const [errors, setErrors] = useState<Fields>({});
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const text = (key: string) => String(form.get(key) ?? "");
|
||||
setStatus({ kind: "sending" });
|
||||
setErrors({});
|
||||
try {
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: text("name"),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
availability: text("availability"),
|
||||
message: text("message"),
|
||||
privacy: form.get("privacy") === "on",
|
||||
website: text("website"),
|
||||
}),
|
||||
});
|
||||
const result = (await response.json().catch(() => null)) as { success: boolean; error?: { message: string; fields?: Fields } } | null;
|
||||
if (response.ok && result?.success) {
|
||||
setStatus({ kind: "sent" });
|
||||
formRef.current?.reset();
|
||||
return;
|
||||
}
|
||||
const fields = result?.error?.fields ?? {};
|
||||
setErrors(fields);
|
||||
setStatus({ kind: "error", message: result?.error?.message ?? "Senden hat nicht geklappt. Bitte versuche es erneut." });
|
||||
const first = FIELD_IDS.find((id) => fields[id]);
|
||||
if (first) requestAnimationFrame(() => document.getElementById(`ct-${first}`)?.focus());
|
||||
} catch {
|
||||
setStatus({ kind: "error", message: "Keine Verbindung. Bitte prüfe dein Internet und versuche es erneut." });
|
||||
}
|
||||
}
|
||||
|
||||
const fieldProps = (id: (typeof FIELD_IDS)[number]) => ({
|
||||
id: `ct-${id}`,
|
||||
name: id,
|
||||
"aria-invalid": errors[id] ? true : undefined,
|
||||
"aria-describedby": errors[id] ? `ct-${id}-error` : undefined,
|
||||
});
|
||||
const error = (id: string) =>
|
||||
errors[id] ? (
|
||||
<p id={`ct-${id}-error`} className="mt-1.5 text-sm font-medium text-rosso">
|
||||
{errors[id]}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section id="erstgespraech" aria-labelledby="erstgespraech-title" className="bg-plaster py-24 md:py-32">
|
||||
<div className="container-edge grid gap-12 md:grid-cols-12">
|
||||
<div className="md:col-span-5">
|
||||
<h2 id="erstgespraech-title" className="section-title">
|
||||
Erstgespräch
|
||||
</h2>
|
||||
<p className="mt-6 max-w-md text-lg text-ink/80">
|
||||
Du bist dir noch unsicher, ob das Modelcamp zu dir passt? Lass uns unverbindlich sprechen. Schreib uns kurz, wann du erreichbar bist, wir melden uns bei dir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-7">
|
||||
{status.kind === "sent" ? (
|
||||
<div role="status" className="rounded-2xl bg-paper p-8 shadow-[0_1px_0_var(--color-line)]">
|
||||
<p className="text-xl font-bold">Danke, deine Anfrage ist angekommen.</p>
|
||||
<p className="mt-2 text-ink/80">Wir melden uns in den nächsten Tagen bei dir.</p>
|
||||
<button type="button" onClick={() => setStatus({ kind: "idle" })} className="btn btn-quiet mt-6">
|
||||
Weitere Anfrage senden
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={submit} noValidate className="relative grid gap-5 rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] md:grid-cols-2 md:p-8">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="ct-name">Name</label>
|
||||
<input {...fieldProps("name")} className="field" autoComplete="name" required maxLength={120} />
|
||||
{error("name")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="ct-email">E-Mail</label>
|
||||
<input {...fieldProps("email")} className="field" type="email" autoComplete="email" inputMode="email" required maxLength={200} />
|
||||
{error("email")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="ct-phone">Telefon (optional)</label>
|
||||
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" maxLength={60} />
|
||||
{error("phone")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="ct-availability">Wann bist du erreichbar? (optional)</label>
|
||||
<input {...fieldProps("availability")} className="field" placeholder="z. B. werktags ab 18 Uhr" maxLength={200} />
|
||||
{error("availability")}
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="field-label" htmlFor="ct-message">Worüber möchtest du sprechen? (optional)</label>
|
||||
<textarea {...fieldProps("message")} className="field min-h-32" rows={4} maxLength={2000} />
|
||||
{error("message")}
|
||||
</div>
|
||||
{/* Falle fuer Bots: nicht sichtbar, nicht per Tab erreichbar. */}
|
||||
<div aria-hidden className="absolute -left-[9999px] h-px w-px overflow-hidden">
|
||||
<label htmlFor="ct-website">Webseite</label>
|
||||
<input id="ct-website" name="website" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="ct-privacy" className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
|
||||
<input {...fieldProps("privacy")} type="checkbox" required className="mt-0.5 h-5 w-5 shrink-0 accent-ink" />
|
||||
<span>
|
||||
Ich bin einverstanden, dass meine Angaben zur Bearbeitung der Anfrage verwendet werden (
|
||||
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
|
||||
Datenschutzhinweise
|
||||
</a>
|
||||
).
|
||||
</span>
|
||||
</label>
|
||||
{error("privacy")}
|
||||
</div>
|
||||
{status.kind === "error" && (
|
||||
<p role="alert" className="rounded-lg border border-rosso/40 bg-plaster p-4 text-[0.9375rem] font-medium text-rosso md:col-span-2">
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="md:col-span-2">
|
||||
<button type="submit" disabled={status.kind === "sending"} className="btn btn-primary px-8 disabled:opacity-60">
|
||||
{status.kind === "sending" ? "Wird gesendet …" : "Erstgespräch anfragen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user