feat: add CMS inquiries and group bookings
This commit is contained in:
+33
-22
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user