feat: Kontaktformular für ein Erstgespräch
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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 11:37:27 +02:00
1 parent d45700a9ef
commit 7745d5862d
9 files changed
+336 -1

No files matched your search

+33
View File
@@ -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 });
}
+1
View File
@@ -21,6 +21,7 @@ export default function DatenschutzPage() {
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li> <li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
<li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li> <li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li>
<li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li> <li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
<li>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).</li>
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li> <li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li>
<li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li> <li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li>
<li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li> <li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
+2
View File
@@ -6,6 +6,7 @@ import { AccommodationSection } from "@/components/sections/CampSections";
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections"; import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection"; import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections"; import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
import { ContactSection } from "@/components/sections/ContactSection";
import { IntroVideoSection } from "@/components/sections/IntroVideoSection"; import { IntroVideoSection } from "@/components/sections/IntroVideoSection";
import { PricingSection } from "@/components/sections/PricingSection"; import { PricingSection } from "@/components/sections/PricingSection";
import { ReviewsSection } from "@/components/sections/ReviewsSection"; import { ReviewsSection } from "@/components/sections/ReviewsSection";
@@ -49,6 +50,7 @@ export default async function HomePage() {
<ReviewsSection data={reviews} showDrafts={showDrafts} /> <ReviewsSection data={reviews} showDrafts={showDrafts} />
<AboutSection showDrafts={showDrafts} /> <AboutSection showDrafts={showDrafts} />
<FaqSection facts={facts} pricing={pricing} /> <FaqSection facts={facts} pricing={pricing} />
<ContactSection />
<ClosingSection pricing={pricing} /> <ClosingSection pricing={pricing} />
</main> </main>
<SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} /> <SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} />
+152
View File
@@ -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>
);
}
+1
View File
@@ -271,4 +271,5 @@ export const NAV_ITEMS = [
{ id: "preis", label: "Preis" }, { id: "preis", label: "Preis" },
{ id: "team", label: "Team" }, { id: "team", label: "Team" },
{ id: "fragen", label: "Fragen" }, { id: "fragen", label: "Fragen" },
{ id: "erstgespraech", label: "Erstgespräch" },
] as const; ] as const;
+61
View File
@@ -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<typeof contactRequestSchema>;
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<string | null> {
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<ContactResult> {
// 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 };
}
+19 -1
View File
@@ -3,12 +3,13 @@
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt". * bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
*/ */
import type { Booking, Installment } from "@/generated/prisma/client"; import type { Booking, Installment } from "@/generated/prisma/client";
import type { ContactRequest } from "./contact";
import { escapeHtml, type EmailDraft } from "./email"; import { escapeHtml, type EmailDraft } from "./email";
import { appUrl } from "./env"; import { appUrl } from "./env";
import { formatEuro } from "./money"; import { formatEuro } from "./money";
import { PLAN_LABEL } from "./payment-plan"; import { PLAN_LABEL } from "./payment-plan";
import { PHASE_LABEL } from "./pricing"; import { PHASE_LABEL } from "./pricing";
import { formatDate } from "./time"; import { formatDate, formatDateTime } from "./time";
type BookingWithInstallments = Booking & { installments: Installment[] }; 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" }) };
}
+1
View File
@@ -15,6 +15,7 @@ export const RATE_LIMITS = {
voucher: { action: "voucher", limit: 10, windowSeconds: 900 }, voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
checkout: { action: "checkout", limit: 8, windowSeconds: 900 }, checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 }, statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
contact: { action: "contact", limit: 5, windowSeconds: 3600 },
} satisfies Record<string, RateLimitRule>; } satisfies Record<string, RateLimitRule>;
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number }; export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
+66
View File
@@ -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<string, unknown> = {}) {
return contactRequestSchema.parse({
name: "Lena Beispiel",
email: "[email protected]",
phone: "",
availability: "abends",
message: "Passt das Camp für Einsteigerinnen?",
privacy: true,
website: "",
...overrides,
});
}
beforeEach(async () => {
await resetDb();
await setupCamp({ organizerEmail: "[email protected]" });
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("[email protected]");
expect(mails[0]!.subject).toContain("Lena Beispiel");
expect(mails[0]!.textBody).toContain("[email protected]");
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 = "[email protected]";
resetEnvCache();
await submitContactRequest(db, request(), ctx);
expect((await db.emailMessage.findFirstOrThrow()).toAddress).toBe("[email protected]");
});
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: "[email protected]", privacy: false }).success).toBe(false);
expect(request({ phone: " " }).phone).toBeNull();
});
});