From 7745d5862d8ce24c49fec5dba15bf59e10d17d1f Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 6 Oct 2026 11:37:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Kontaktformular=20f=C3=BCr=20ein=20Erst?= =?UTF-8?q?gespr=C3=A4ch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/contact/route.ts | 33 +++++ src/app/datenschutz/page.tsx | 1 + src/app/page.tsx | 2 + src/components/sections/ContactSection.tsx | 152 +++++++++++++++++++++ src/content/site.ts | 1 + src/lib/contact.ts | 61 +++++++++ src/lib/email-templates.ts | 20 ++- src/lib/rate-limit.ts | 1 + tests/contact.integration.test.ts | 66 +++++++++ 9 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 src/app/api/contact/route.ts create mode 100644 src/components/sections/ContactSection.tsx create mode 100644 src/lib/contact.ts create mode 100644 tests/contact.integration.test.ts diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..ed34a75 --- /dev/null +++ b/src/app/api/contact/route.ts @@ -0,0 +1,33 @@ +/** + * Formular "Erstgespraech" (src/components/sections/ContactSection.tsx). + * Gleiche Schutzschicht wie die Buchung: nur JSON von der eigenen Seite, + * Rate Limit je IP, serverseitige Pruefung aller Felder. + */ +import { after } from "next/server"; +import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api"; +import { contactRequestSchema, submitContactRequest } from "@/lib/contact"; +import { getDb } from "@/lib/db"; +import { flushEmails } from "@/lib/email"; +import { requireAppSecret } from "@/lib/env"; +import { RATE_LIMITS } from "@/lib/rate-limit"; + +export async function POST(request: Request) { + const blocked = checkSameOrigin(request); + if (blocked) return blocked; + const limited = await rateLimited(request, RATE_LIMITS.contact); + if (limited) return limited; + const body = await readJson(request, contactRequestSchema); + if (!body.ok) return body.response; + + const db = getDb(); + const result = await submitContactRequest(db, body.data, { now: new Date(), secret: requireAppSecret() }); + if (!result.ok) { + return fail(503, { code: "NOT_CONFIGURED", message: "Das Formular ist gerade nicht verfügbar. Bitte schreib uns direkt eine E-Mail." }); + } + // Sofort zustellen statt auf den naechsten Worker-Lauf zu warten; schlaegt + // das fehl, versucht es der Worker erneut. + if (result.delivered) { + after(() => flushEmails(db, { limit: 5 }).catch(() => undefined)); + } + return ok({ received: true }); +} diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx index 2a00bff..8182960 100644 --- a/src/app/datenschutz/page.tsx +++ b/src/app/datenschutz/page.tsx @@ -21,6 +21,7 @@ export default function DatenschutzPage() {
  • Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.
  • Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.
  • E-Mail-Versand über den konfigurierten SMTP-Anbieter.
  • +
  • Formular Erstgespräch: Name, E-Mail, optional Telefon, Erreichbarkeit und Nachricht; wird nicht in einer eigenen Tabelle gespeichert, sondern als E-Mail an den Veranstalter geschickt (Kopie im Postausgang der Datenbank).
  • Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.
  • Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.
  • Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.
  • diff --git a/src/app/page.tsx b/src/app/page.tsx index ed8bca7..65d54c9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { AccommodationSection } from "@/components/sections/CampSections"; import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections"; import { ClosingSection, FaqSection } from "@/components/sections/FaqSection"; import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections"; +import { ContactSection } from "@/components/sections/ContactSection"; import { IntroVideoSection } from "@/components/sections/IntroVideoSection"; import { PricingSection } from "@/components/sections/PricingSection"; import { ReviewsSection } from "@/components/sections/ReviewsSection"; @@ -49,6 +50,7 @@ export default async function HomePage() { + diff --git a/src/components/sections/ContactSection.tsx b/src/components/sections/ContactSection.tsx new file mode 100644 index 0000000..2756617 --- /dev/null +++ b/src/components/sections/ContactSection.tsx @@ -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; + +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(null); + const [status, setStatus] = useState({ kind: "idle" }); + const [errors, setErrors] = useState({}); + + async function submit(event: FormEvent) { + 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] ? ( +

    + {errors[id]} +

    + ) : null; + + return ( +
    +
    +
    +

    + Erstgespräch +

    +

    + 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. +

    +
    + +
    + {status.kind === "sent" ? ( +
    +

    Danke, deine Anfrage ist angekommen.

    +

    Wir melden uns in den nächsten Tagen bei dir.

    + +
    + ) : ( +
    +
    + + + {error("name")} +
    +
    + + + {error("email")} +
    +
    + + + {error("phone")} +
    +
    + + + {error("availability")} +
    +
    + +