feat: add CMS inquiries and group bookings

This commit is contained in:
2026-09-17 15:48:40 +02:00
parent 97dd32a2b4
commit a2b3e99174
28 changed files with 1200 additions and 45 deletions
+33 -22
View File
@@ -5,37 +5,48 @@ import { track } from "@/lib/analytics/client";
import { TextField, TextAreaField } from "@/components/FormField";
type ContactFormProps = {
toEmail: string;
toEmail?: string;
subjectPrefix?: string;
type?: "contact" | "appointment";
submitLabel?: string;
};
/**
* Opens the visitor's email client with a pre-filled message. There is no
* backend configured yet — swap this for a Server Action once a mail
* provider is connected.
*/
export function ContactForm({
toEmail,
subjectPrefix = "Nachricht über anouma.org",
subjectPrefix = "Allgemeine Anfrage",
type = "contact",
submitLabel = "Nachricht senden",
}: ContactFormProps) {
const [sent, setSent] = useState(false);
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
const [submitting, setSubmitting] = useState(false);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const name = String(data.get("name") ?? "");
const email = String(data.get("email") ?? "");
const message = String(data.get("message") ?? "");
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
const body = encodeURIComponent(`${message}\n\n– ${name} (${email})`);
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
// Event only — never the form's name/email/message content, see
// lib/analytics/events.ts and the data-minimization requirement.
track("contact_clicked");
setSent(true);
setSubmitting(true);
setState({ sent: false });
try {
const response = await fetch("/api/contact/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message, subject: `${subjectPrefix} von ${name}`, type }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
setState({ sent: false, error: result.error || "Die Nachricht konnte nicht gesendet werden." });
setSubmitting(false);
return;
}
track("contact_clicked");
setState({ sent: true });
e.currentTarget.reset();
} catch {
setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." });
} finally {
setSubmitting(false);
}
}
return (
@@ -47,14 +58,14 @@ export function ContactForm({
type="submit"
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
>
{submitLabel}
{submitting ? "Wird gesendet …" : submitLabel}
</button>
{sent && (
{state.sent && (
<p className="text-sm text-anouma-olive" role="status">
Dein E-Mail-Programm sollte sich jetzt geöffnet haben. Falls nicht, schreib gerne direkt an{" "}
{toEmail}.
Danke, deine Nachricht ist angekommen. Anna antwortet dir per E-Mail; du kannst direkt auf ihre Antwort reagieren.
</p>
)}
{state.error && <p className="text-sm text-red-700" role="alert">{state.error}</p>}
</form>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useState, type FormEvent } from "react";
import { TextField, TextAreaField } from "@/components/FormField";
export function GroupRegistrationForm({ offerSlug }: { offerSlug: string }) {
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setState({ sent: false });
const data = new FormData(event.currentTarget);
try {
const response = await fetch("/api/group-registration", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
offerSlug,
name: data.get("name"),
email: data.get("email"),
phone: data.get("phone"),
message: data.get("message"),
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) setState({ sent: false, error: result.error || "Die Anmeldung konnte nicht gesendet werden." });
else { setState({ sent: true }); event.currentTarget.reset(); }
} catch { setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." }); }
finally { setBusy(false); }
}
if (state.sent) return <p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-olive" role="status">Danke, deine Anmeldung ist angekommen. Anna meldet sich, sobald sie die Plätze geprüft hat.</p>;
return (
<form onSubmit={submit} className="space-y-5">
<TextField label="Name" id="group-name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="group-email" name="email" type="email" required autoComplete="email" />
<TextField label="Telefon (optional)" id="group-phone" name="phone" type="tel" autoComplete="tel" />
<TextAreaField label="Nachricht / Motivation (optional)" id="group-message" name="message" rows={4} />
{state.error && <p className="text-sm text-red-700" role="alert">{state.error}</p>}
<button type="submit" disabled={busy} className="rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum disabled:opacity-60">{busy ? "Wird gesendet …" : "Anmeldung senden"}</button>
</form>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
type Conversation = { id: string; subject: string; name: string; email: string; status: string; lastMessageAt?: string };
type Message = { id: string; senderType: "visitor" | "admin"; senderName?: string; body: string; createdAt: string };
const statusLabel: Record<string, string> = { open: "Offen", waiting: "Wartet auf Antwort", closed: "Erledigt" };
export function AdminConversationsView() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selected, setSelected] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [reply, setReply] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function loadConversations() {
const response = await fetch("/api/conversations?limit=100&sort=-lastMessageAt&depth=0", { credentials: "include" });
if (!response.ok) throw new Error("Anfragen konnten nicht geladen werden.");
const data = await response.json();
setConversations(data.docs ?? []);
}
async function loadMessages(conversation: Conversation) {
setSelected(conversation);
const response = await fetch(`/api/conversation-messages?limit=100&sort=createdAt&where[conversation][equals]=${encodeURIComponent(conversation.id)}`, { credentials: "include" });
const data = await response.json();
setMessages(data.docs ?? []);
}
useEffect(() => {
let cancelled = false;
fetch("/api/conversations?limit=100&sort=-lastMessageAt&depth=0", { credentials: "include" })
.then(async (response) => {
if (!response.ok) throw new Error("Anfragen konnten nicht geladen werden.");
return response.json();
})
.then((data) => { if (!cancelled) setConversations(data.docs ?? []); })
.catch((err: Error) => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
async function sendReply() {
if (!selected || !reply.trim()) return;
setBusy(true);
setError("");
try {
const response = await fetch("/api/conversation-messages", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conversation: selected.id, senderType: "admin", senderName: "Anouma", body: reply.trim() }),
});
if (!response.ok) throw new Error("Antwort konnte nicht gesendet werden.");
setReply("");
await loadMessages(selected);
await loadConversations();
} catch (err) { setError(err instanceof Error ? err.message : "Antwort konnte nicht gesendet werden."); }
finally { setBusy(false); }
}
return (
<main style={{ maxWidth: 1200, padding: "32px 24px" }}>
<h1 style={{ margin: 0, fontSize: 28 }}>Anfragen & Nachrichten</h1>
<p style={{ margin: "8px 0 24px", color: "#66505f" }}>Kontakt- und Terminanfragen zentral bearbeiten. Antworten werden automatisch per E-Mail verschickt.</p>
{error && <p style={{ color: "#a33", marginBottom: 16 }}>{error}</p>}
<div style={{ display: "grid", gridTemplateColumns: "minmax(260px, .8fr) minmax(0, 1.4fr)", gap: 20, minHeight: 560 }}>
<section style={{ border: "1px solid #e6dcd7", borderRadius: 16, overflow: "auto" }}>
{loading && <p style={{ padding: 20 }}>Anfragen werden geladen …</p>}
{!loading && conversations.length === 0 && <p style={{ padding: 20 }}>Noch keine Anfragen.</p>}
{conversations.map((conversation) => (
<button key={conversation.id} type="button" onClick={() => loadMessages(conversation)} style={{ display: "block", width: "100%", padding: 16, textAlign: "left", border: 0, borderBottom: "1px solid #eee5e1", background: selected?.id === conversation.id ? "#fbf6f1" : "white", cursor: "pointer" }}>
<strong style={{ display: "block", color: "#66505f" }}>{conversation.subject}</strong>
<span style={{ display: "block", marginTop: 4, fontSize: 13 }}>{conversation.name} · {conversation.email}</span>
<span style={{ display: "block", marginTop: 6, fontSize: 12, color: "#8b616d" }}>{statusLabel[conversation.status] ?? conversation.status}{conversation.lastMessageAt ? ` · ${new Date(conversation.lastMessageAt).toLocaleDateString("de-DE")}` : ""}</span>
</button>
))}
</section>
<section style={{ display: "flex", flexDirection: "column", border: "1px solid #e6dcd7", borderRadius: 16, padding: 20 }}>
{!selected ? <p style={{ color: "#66505f" }}>Wähle links eine Anfrage aus.</p> : <>
<div style={{ borderBottom: "1px solid #eee5e1", paddingBottom: 14 }}><h2 style={{ margin: 0, fontSize: 20 }}>{selected.subject}</h2><p style={{ margin: "6px 0 0", fontSize: 13 }}>{selected.name} · {selected.email}</p></div>
<div style={{ flex: 1, overflow: "auto", padding: "18px 0" }}>
{messages.map((message) => <div key={message.id} style={{ maxWidth: "82%", margin: "0 0 14px auto", padding: 14, borderRadius: 14, background: message.senderType === "admin" ? "#f2e2d6" : "#fbf6f1", marginLeft: message.senderType === "admin" ? "18%" : 0 }}><div style={{ fontSize: 12, color: "#8b616d", marginBottom: 6 }}>{message.senderType === "admin" ? "Anouma" : message.senderName || selected.name} · {new Date(message.createdAt).toLocaleString("de-DE")}</div><div style={{ whiteSpace: "pre-wrap", lineHeight: 1.55 }}>{message.body}</div></div>)}
</div>
<div style={{ display: "flex", gap: 10, alignItems: "flex-end" }}><textarea value={reply} onChange={(event) => setReply(event.target.value)} placeholder="Antwort schreiben …" rows={4} style={{ flex: 1, resize: "vertical", border: "1px solid #d8c9c3", borderRadius: 12, padding: 12, font: "inherit" }} /><button type="button" disabled={busy || !reply.trim()} onClick={sendReply} style={{ border: 0, borderRadius: 999, padding: "12px 18px", background: "#8b616d", color: "white", cursor: "pointer" }}>{busy ? "Sende …" : "Antwort senden"}</button></div>
</>}
</section>
</div>
</main>
);
}
@@ -0,0 +1,5 @@
import Link from "next/link";
export function ConversationsNavLink() {
return <div style={{ padding: "0 8px 8px" }}><Link href="/admin/anfragen" style={{ display: "block", padding: "8px 12px", borderRadius: 6, fontSize: 13, fontWeight: 600, color: "#8b616d", textDecoration: "none", border: "1px solid #e2e2e2" }}>💬 Anfragen</Link></div>;
}
+3 -3
View File
@@ -56,9 +56,9 @@ export function BookingWidget({
{fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
</p>
)}
<p className="mt-4 text-sm font-medium text-anouma-plum">Status: Termin vorgeschlagen</p>
<p className="mt-4 text-sm font-medium text-anouma-plum">Status: Termin gebucht</p>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
Du erhältst gleich eine Bestätigungs-E-Mail mit allen Details.
</p>
</div>
);
@@ -212,7 +212,7 @@ export function BookingWidget({
disabled={submitting}
className="mt-5 rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
>
{submitting ? "Wird gesendet …" : "Terminanfrage senden"}
{submitting ? "Wird gebucht …" : "Termin buchen"}
</button>
</div>
)}