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.
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/content/site.ts b/src/content/site.ts
index c413125..56b6b86 100644
--- a/src/content/site.ts
+++ b/src/content/site.ts
@@ -271,4 +271,5 @@ export const NAV_ITEMS = [
{ id: "preis", label: "Preis" },
{ id: "team", label: "Team" },
{ id: "fragen", label: "Fragen" },
+ { id: "erstgespraech", label: "Erstgespräch" },
] as const;
diff --git a/src/lib/contact.ts b/src/lib/contact.ts
new file mode 100644
index 0000000..23bc4a6
--- /dev/null
+++ b/src/lib/contact.ts
@@ -0,0 +1,61 @@
+/**
+ * Anfrage fuer ein unverbindliches Erstgespraech. Kein eigenes Datenmodell:
+ * die Anfrage geht als E-Mail in den Postausgang (EmailMessage, mit
+ * Wiederholungen und Anzeige unzustellbarer Mails im Admin) an den
+ * Veranstalter bzw. ADMIN_NOTIFY_EMAIL.
+ *
+ * Bewusst keine automatische Bestaetigungsmail an die angegebene Adresse:
+ * sonst liesse sich das Formular nutzen, um Dritten Mails zu schicken.
+ */
+import "./zod-de";
+import { z } from "zod";
+import { audit, CUSTOMER } from "./audit";
+import { getCamp } from "./camp";
+import type { Db } from "./db";
+import { enqueueEmail } from "./email";
+import { contactInquiryEmail } from "./email-templates";
+import { env } from "./env";
+import { hmacHex } from "./tokens";
+
+const optionalText = (max: number) =>
+ z
+ .string()
+ .trim()
+ .max(max)
+ .optional()
+ .transform((v) => v || null);
+
+export const contactRequestSchema = z.object({
+ name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
+ email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
+ phone: optionalText(60),
+ availability: optionalText(200),
+ message: optionalText(2000),
+ privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
+ /** Honeypot: fuer Menschen unsichtbar, Bots fuellen es aus. */
+ website: z.string().max(200).optional(),
+});
+
+export type ContactRequest = z.infer;
+export type ContactResult = { ok: true; delivered: boolean } | { ok: false; error: "NOT_CONFIGURED" };
+
+/** Empfaenger: Veranstalter-Adresse aus dem Admin, sonst ADMIN_NOTIFY_EMAIL. */
+export async function contactRecipient(db: Db): Promise {
+ const camp = await getCamp(db);
+ return camp.organizerEmail || env().ADMIN_NOTIFY_EMAIL || null;
+}
+
+export async function submitContactRequest(db: Db, input: ContactRequest, ctx: { now: Date; secret: string }): Promise {
+ // Bot: so tun, als waere alles gut, aber nichts verschicken.
+ if (input.website) return { ok: true, delivered: false };
+ const to = await contactRecipient(db);
+ if (!to) return { ok: false, error: "NOT_CONFIGURED" };
+
+ // Doppelt abgeschicktes Formular (gleiche Angaben in derselben Minute)
+ // ergibt nur eine Mail.
+ const minute = Math.floor(ctx.now.getTime() / 60_000);
+ const fingerprint = hmacHex(ctx.secret, `contact:${input.email.toLowerCase()}:${input.message ?? ""}:${minute}`).slice(0, 32);
+ await enqueueEmail(db, contactInquiryEmail(to, input, `contact:${fingerprint}`, ctx.now));
+ await audit(db, CUSTOMER, "contact.requested", "Contact", fingerprint);
+ return { ok: true, delivered: true };
+}
diff --git a/src/lib/email-templates.ts b/src/lib/email-templates.ts
index 3095d69..e305a4a 100644
--- a/src/lib/email-templates.ts
+++ b/src/lib/email-templates.ts
@@ -3,12 +3,13 @@
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
*/
import type { Booking, Installment } from "@/generated/prisma/client";
+import type { ContactRequest } from "./contact";
import { escapeHtml, type EmailDraft } from "./email";
import { appUrl } from "./env";
import { formatEuro } from "./money";
import { PLAN_LABEL } from "./payment-plan";
import { PHASE_LABEL } from "./pricing";
-import { formatDate } from "./time";
+import { formatDate, formatDateTime } from "./time";
type BookingWithInstallments = Booking & { installments: Installment[] };
@@ -163,3 +164,20 @@ export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft
),
);
}
+
+/** Anfrage fuer ein Erstgespraech an den Veranstalter (src/lib/contact.ts). */
+export function contactInquiryEmail(to: string, inquiry: ContactRequest, dedupeKey: string, receivedAt: Date): EmailDraft {
+ const subject = `Anfrage Erstgespräch: ${inquiry.name}`;
+ const lines = [
+ `Über die Webseite kam eine Anfrage für ein Erstgespräch (${formatDateTime(receivedAt)}).`,
+ [
+ `Name: ${inquiry.name}`,
+ `E-Mail: ${inquiry.email}`,
+ `Telefon: ${inquiry.phone ?? "–"}`,
+ `Erreichbar: ${inquiry.availability ?? "–"}`,
+ ].join("\n"),
+ inquiry.message ? `Nachricht:\n${inquiry.message}` : "Keine Nachricht angegeben.",
+ ];
+ const reply = `mailto:${encodeURIComponent(inquiry.email)}?subject=${encodeURIComponent(`Dein Erstgespräch – ${BRAND}`)}`;
+ return { dedupeKey, to, subject: `${subject} | ${BRAND}`, ...wrap(subject, lines, { href: reply, label: "Antworten" }) };
+}
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
index a194f50..32d433c 100644
--- a/src/lib/rate-limit.ts
+++ b/src/lib/rate-limit.ts
@@ -15,6 +15,7 @@ export const RATE_LIMITS = {
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
+ contact: { action: "contact", limit: 5, windowSeconds: 3600 },
} satisfies Record;
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
diff --git a/tests/contact.integration.test.ts b/tests/contact.integration.test.ts
new file mode 100644
index 0000000..48ec55c
--- /dev/null
+++ b/tests/contact.integration.test.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, test } from "vitest";
+import { contactRequestSchema, submitContactRequest } from "@/lib/contact";
+import { resetEnvCache } from "@/lib/env";
+import { db, resetDb, SECRET, setupCamp } from "./support/db";
+
+const NOW = new Date("2026-11-15T10:00:00Z");
+const ctx = { now: NOW, secret: SECRET };
+
+function request(overrides: Record = {}) {
+ return contactRequestSchema.parse({
+ name: "Lena Beispiel",
+ email: "lena@example.org",
+ phone: "",
+ availability: "abends",
+ message: "Passt das Camp für Einsteigerinnen?",
+ privacy: true,
+ website: "",
+ ...overrides,
+ });
+}
+
+beforeEach(async () => {
+ await resetDb();
+ await setupCamp({ organizerEmail: "veranstalter@example.org" });
+ delete process.env.ADMIN_NOTIFY_EMAIL;
+ resetEnvCache();
+});
+
+describe("Erstgespräch", () => {
+ test("Anfrage landet als Mail beim Veranstalter, mit allen Angaben", async () => {
+ expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: true, delivered: true });
+ const mails = await db.emailMessage.findMany();
+ expect(mails).toHaveLength(1);
+ expect(mails[0]!.toAddress).toBe("veranstalter@example.org");
+ expect(mails[0]!.subject).toContain("Lena Beispiel");
+ expect(mails[0]!.textBody).toContain("lena@example.org");
+ expect(mails[0]!.textBody).toContain("Passt das Camp für Einsteigerinnen?");
+ expect(mails[0]!.htmlBody).toContain("mailto:lena%40example.org");
+ });
+
+ test("doppelt abgeschickt ergibt nur eine Mail", async () => {
+ await submitContactRequest(db, request(), ctx);
+ await submitContactRequest(db, request(), { ...ctx, now: new Date(NOW.getTime() + 10_000) });
+ expect(await db.emailMessage.count()).toBe(1);
+ });
+
+ test("Bot im Honeypot: Erfolg vortäuschen, nichts verschicken", async () => {
+ expect(await submitContactRequest(db, request({ website: "https://spam.example" }), ctx)).toEqual({ ok: true, delivered: false });
+ expect(await db.emailMessage.count()).toBe(0);
+ });
+
+ test("ohne Empfänger nicht verfügbar, mit ADMIN_NOTIFY_EMAIL als Ersatz", async () => {
+ await setupCamp({ organizerEmail: null });
+ expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: false, error: "NOT_CONFIGURED" });
+ process.env.ADMIN_NOTIFY_EMAIL = "admin@example.org";
+ resetEnvCache();
+ await submitContactRequest(db, request(), ctx);
+ expect((await db.emailMessage.findFirstOrThrow()).toAddress).toBe("admin@example.org");
+ });
+
+ test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => {
+ expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false);
+ expect(contactRequestSchema.safeParse({ name: "Lena", email: "lena@example.org", privacy: false }).success).toBe(false);
+ expect(request({ phone: " " }).phone).toBeNull();
+ });
+});