Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m50s
Gleiche Prüfung wie bei der Buchung: mindestens 6 Zeichen, nur Ziffern, Leerzeichen und + ( ) / -. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
153 lines
7.1 KiB
TypeScript
153 lines
7.1 KiB
TypeScript
"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</label>
|
|
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" required maxLength={40} />
|
|
{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>
|
|
);
|
|
}
|