From a2b3e99174b7dd618c2f33def68a9c30a881b28c Mon Sep 17 00:00:00 2001 From: maro Date: Thu, 17 Sep 2026 15:48:40 +0200 Subject: [PATCH] feat: add CMS inquiries and group bookings --- .env.example | 7 + DEPLOYMENT.md | 1 + README.md | 11 ++ app/(frontend)/angebote/[slug]/page.tsx | 20 ++- app/(frontend)/kontakt/page.tsx | 13 +- app/(frontend)/termin-buchen/page.tsx | 28 +++- app/(payload)/admin/importMap.js | 8 + app/(payload)/api/booking/request/route.ts | 5 +- app/(payload)/api/contact/inquiry/route.ts | 87 ++++++++++ app/(payload)/api/email/inbound/route.ts | 94 +++++++++++ app/(payload)/api/group-registration/route.ts | 45 +++++ collections/ConversationMessages.ts | 102 +++++++++++ collections/Conversations.ts | 88 ++++++++++ collections/GroupRegistrations.ts | 50 ++++++ collections/Offers.ts | 66 ++++++++ components/ContactForm.tsx | 55 +++--- components/GroupRegistrationForm.tsx | 46 +++++ components/admin/AdminConversationsView.tsx | 94 +++++++++++ components/admin/ConversationsNavLink.tsx | 5 + components/booking/BookingWidget.tsx | 6 +- lib/email/conversationTemplates.ts | 61 +++++++ lib/email/sendBookingEmails.ts | 14 +- ...0260917_154000_communication_and_groups.ts | 145 ++++++++++++++++ migrations/index.ts | 6 + payload-types.ts | 158 ++++++++++++++++++ payload.config.ts | 13 ++ scripts/seed.ts | 15 +- setup.sh | 2 + 28 files changed, 1200 insertions(+), 45 deletions(-) create mode 100644 app/(payload)/api/contact/inquiry/route.ts create mode 100644 app/(payload)/api/email/inbound/route.ts create mode 100644 app/(payload)/api/group-registration/route.ts create mode 100644 collections/ConversationMessages.ts create mode 100644 collections/Conversations.ts create mode 100644 collections/GroupRegistrations.ts create mode 100644 components/GroupRegistrationForm.tsx create mode 100644 components/admin/AdminConversationsView.tsx create mode 100644 components/admin/ConversationsNavLink.tsx create mode 100644 lib/email/conversationTemplates.ts create mode 100644 migrations/20260917_154000_communication_and_groups.ts diff --git a/.env.example b/.env.example index 80d1356..7eaf7d3 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,13 @@ SMTP_USER= SMTP_PASSWORD= SMTP_FROM="ANOUMA " +# Address used as Reply-To for CMS replies. Configure this mailbox with an +# inbound provider/webhook that POSTs replies to /api/email/inbound. +INBOUND_REPLY_TO=reply-to@anouma.org +# Shared secret sent as x-inbound-secret (or Authorization: Bearer ...) by the +# inbound email provider. Generate with: openssl rand -hex 32 +INBOUND_EMAIL_SECRET= + # ── LiveKit SFU / embedded TURN ───────────────────────────────────────── # Public HTTPS endpoint of the self-hosted LiveKit server. The app converts # https:// to wss:// for browsers and uses the same endpoint for its API. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index f520793..d2553ae 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -191,6 +191,7 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file | `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards | | `NEXT_PUBLIC_SERVER_URL` | Public URL of the site | | `SMTP_*` | The existing ANOUMA mail system | +| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) | | `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung | | `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names | | `BACKUP_RETENTION_DAYS` | Deploy-backup retention | diff --git a/README.md b/README.md index 9befe59..685db0e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,17 @@ Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next ## Online-Termine & Video-Call +## Kontaktanfragen und CMS-Nachrichten + +Kontakt- und Terminanfragen werden unter `/admin/anfragen` als Konversationen +gespeichert. Antworten aus dem CMS werden per SMTP verschickt. Setze dafür +`INBOUND_REPLY_TO` auf die Mailbox, auf die Besucher antworten sollen. Ein +Inbound-Mail-Provider muss eingehende Antworten anschließend als JSON an +`POST /api/email/inbound` weiterleiten und dabei `x-inbound-secret` mit dem +Wert aus `INBOUND_EMAIL_SECRET` senden. Erwartet werden mindestens `from`, +`subject`, `text` und eine eindeutige `messageId`; der Thread-Schlüssel im +Betreff (`[ANOUMA-…]`) ordnet die Antwort der richtigen Anfrage zu. + - Ein Termin wird per Häkchen „Online-Termin“ zu einem Video-Call-Termin. Payload generiert dabei automatisch ein zufälliges Meeting-Passwort (nie aus der Meeting-ID abgeleitet); die Admin kann jederzeit ein neues erzeugen lassen. - Öffentliche Beitrittsseite: `/termine/[slug]/beitreten` (Name + Passwort, beide Pflicht) → bei Erfolg `/termine/[slug]/call`. - Ist die aufrufende Person im selben Browser als Admin eingeloggt, wird sie automatisch als **Host** erkannt (kein Passwort nötig, größeres Beitritts-Zeitfenster, exklusive Steuerung: Bildschirmfreigabe, Teilnehmer entfernen). diff --git a/app/(frontend)/angebote/[slug]/page.tsx b/app/(frontend)/angebote/[slug]/page.tsx index 9736170..b5a22a4 100644 --- a/app/(frontend)/angebote/[slug]/page.tsx +++ b/app/(frontend)/angebote/[slug]/page.tsx @@ -6,6 +6,7 @@ import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { RichText } from "@/components/RichText"; import { CTA } from "@/components/CTA"; import { BookingWidget } from "@/components/booking/BookingWidget"; +import { GroupRegistrationForm } from "@/components/GroupRegistrationForm"; import { getOfferBySlug } from "@/lib/payload/content"; import { moodForOffer } from "@/lib/angebote"; import { mediaAlt, mediaUrl } from "@/lib/payload/media"; @@ -23,6 +24,7 @@ import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; type Args = { params: Promise<{ slug: string }> }; +const WEEKDAY_LABELS: Record = { monday: "Montag", tuesday: "Dienstag", wednesday: "Mittwoch", thursday: "Donnerstag", friday: "Freitag", saturday: "Samstag", sunday: "Sonntag" }; async function resolveOfferOrRedirect(slug: string) { const offer = await getOfferBySlug(slug); @@ -123,7 +125,23 @@ export default async function OfferDetailPage({ params }: Args) { - {offer.bookable ? ( + {offer.bookingMode === "group" ? ( +
+
+

Für die Gruppe anmelden

+ {offer.groupInfo &&

{offer.groupInfo}

} + {offer.groupSchedule && ( +
+

Fester Termin

+

{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `–${offer.groupSchedule.endTime}` : ""}

+
+ )} + {offer.groupCapacity &&

Plätze: {offer.groupCapacity}

} + {offer.registrationDeadline &&

Anmeldung möglich bis {new Date(offer.registrationDeadline).toLocaleDateString("de-DE")}

} + +
+
+ ) : offer.bookable ? (

Termin auswählen

diff --git a/app/(frontend)/kontakt/page.tsx b/app/(frontend)/kontakt/page.tsx index 9de36c2..c3a7b29 100644 --- a/app/(frontend)/kontakt/page.tsx +++ b/app/(frontend)/kontakt/page.tsx @@ -32,9 +32,11 @@ export async function generateMetadata(): Promise { }); } -export default async function KontaktPage() { +export default async function KontaktPage({ searchParams }: { searchParams: Promise<{ subject?: string }> }) { const contact = await getContactGlobal(); const siteUrl = await getSiteUrl(); + const params = await searchParams; + const isAppointment = params.subject === "Terminanfrage"; return ( <> @@ -58,7 +60,7 @@ export default async function KontaktPage() {
- + @@ -70,12 +72,9 @@ export default async function KontaktPage() { E-Mail - + {contact.email} - +

{contact.phone && (

diff --git a/app/(frontend)/termin-buchen/page.tsx b/app/(frontend)/termin-buchen/page.tsx index 750f5ee..1eef7e4 100644 --- a/app/(frontend)/termin-buchen/page.tsx +++ b/app/(frontend)/termin-buchen/page.tsx @@ -4,7 +4,7 @@ import { Section, SectionHeading } from "@/components/Section"; import { ContactForm } from "@/components/ContactForm"; import { AngebotCard } from "@/components/AngebotCard"; import { Reveal } from "@/components/Reveal"; -import { getBookingGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals"; +import { getBookingGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals"; import { getOffers } from "@/lib/payload/content"; import { moodForOffer } from "@/lib/angebote"; import { mediaUrl } from "@/lib/payload/media"; @@ -36,7 +36,7 @@ export async function generateMetadata(): Promise { } export default async function TerminBuchenPage() { - const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]); + const [booking, offers] = await Promise.all([getBookingGlobal(), getOffers()]); const bookableOffers = offers.filter((offer) => offer.bookable); const siteUrl = await getSiteUrl(); @@ -56,8 +56,25 @@ export default async function TerminBuchenPage() { crumbs={[{ title: "Termin buchen" }]} /> +

+ +
+ {bookableOffers.length > 0 && ( -
+
{bookableOffers.map((offer, i) => ( @@ -105,14 +122,11 @@ export default async function TerminBuchenPage() { />
- {booking.formNote && ( -

{booking.formNote}

- )}
diff --git a/app/(payload)/admin/importMap.js b/app/(payload)/admin/importMap.js index b20e0af..5f8a7b7 100644 --- a/app/(payload)/admin/importMap.js +++ b/app/(payload)/admin/importMap.js @@ -22,7 +22,11 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink' +import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink' +import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink' import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView' +import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView' +import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' /** @type import('payload').ImportMap */ @@ -51,6 +55,10 @@ export const importMap = { "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251, + "/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962, + "/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181, "/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a, + "/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0, + "/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/app/(payload)/api/booking/request/route.ts b/app/(payload)/api/booking/request/route.ts index d078983..6800427 100644 --- a/app/(payload)/api/booking/request/route.ts +++ b/app/(payload)/api/booking/request/route.ts @@ -101,7 +101,10 @@ export async function POST(request: Request) { startTime: start.toISOString(), endTime: end.toISOString(), appointmentType, - status: "pending", + // Calendar bookings use already published availability, so a slot + // selected here is booked immediately. The separate contact form + // remains the path for requests that need personal coordination. + status: "confirmed", userMessage: message || undefined, }, }); diff --git a/app/(payload)/api/contact/inquiry/route.ts b/app/(payload)/api/contact/inquiry/route.ts new file mode 100644 index 0000000..0e80361 --- /dev/null +++ b/app/(payload)/api/contact/inquiry/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { randomUUID } from "node:crypto"; +import { getPayload } from "payload"; +import config from "@payload-config"; +import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit"; +import { newConversationAdminEmail } from "@/lib/email/conversationTemplates"; +import { sendEmail } from "@/lib/email/sendBookingEmails"; + +export const dynamic = "force-dynamic"; + +type Body = { + name?: unknown; + email?: unknown; + message?: unknown; + subject?: unknown; + type?: unknown; +}; + +function clean(value: unknown, max: number): string { + return typeof value === "string" ? value.trim().slice(0, max) : ""; +} + +export async function POST(request: Request) { + let body: Body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + + const name = clean(body.name, 160); + const email = clean(body.email, 254).toLowerCase(); + const message = clean(body.message, 5000); + const subject = clean(body.subject, 180) || "Allgemeine Anfrage"; + const type = body.type === "appointment" ? "appointment" : "contact"; + + if (!name || !email || !message || !/^\S+@\S+\.\S+$/.test(email)) { + return NextResponse.json({ error: "Bitte fülle alle Pflichtfelder korrekt aus." }, { status: 400 }); + } + if (!checkRateLimit(`contact:ip:${getClientIp(request)}`, { max: 8, windowMs: 60 * 60 * 1000 })) { + return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 }); + } + + try { + const payload = await getPayload({ config }); + const { user } = await payload.auth({ headers: request.headers }); + const customerId = user?.collection === "customers" ? user.id : undefined; + const conversation = await payload.create({ + collection: "conversations", + overrideAccess: true, + data: { + name, + email, + subject, + type, + status: "open", + threadKey: randomUUID(), + ...(customerId ? { customer: customerId } : {}), + lastMessageAt: new Date().toISOString(), + }, + }); + await payload.create({ + collection: "conversation-messages", + overrideAccess: true, + data: { + conversation: conversation.id, + senderType: "visitor", + senderName: name, + senderEmail: email, + body: message, + }, + }); + + const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true }); + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin; + const adminUrl = `${serverUrl}/admin/collections/conversations/${conversation.id}`; + if (contact.email) { + await sendEmail(contact.email, newConversationAdminEmail({ name, email, subject, body: message, adminUrl, threadKey: conversation.threadKey }), { + replyTo: process.env.INBOUND_REPLY_TO || "reply-to@anouma.org", + }); + } + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("contact inquiry failed", error); + return NextResponse.json({ error: "Die Nachricht konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 }); + } +} diff --git a/app/(payload)/api/email/inbound/route.ts b/app/(payload)/api/email/inbound/route.ts new file mode 100644 index 0000000..2402694 --- /dev/null +++ b/app/(payload)/api/email/inbound/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; +import { getPayload } from "payload"; +import config from "@payload-config"; + +export const dynamic = "force-dynamic"; + +type InboundBody = { + from?: unknown; + sender?: unknown; + email?: unknown; + name?: unknown; + subject?: unknown; + text?: unknown; + body?: unknown; + html?: unknown; + messageId?: unknown; + inReplyTo?: unknown; +}; + +function stringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function extractEmail(value: unknown): string { + if (value && typeof value === "object" && "email" in value) return extractEmail((value as { email?: unknown }).email); + const raw = stringValue(value); + const match = raw.match(/<([^>]+)>/) || raw.match(/([\w.+-]+@[\w.-]+\.[A-Za-z]{2,})/); + return (match?.[1] || raw).trim().toLowerCase(); +} + +function stripHtml(value: string): string { + return value.replace(//gi, "").replace(//gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); +} + +function findThreadKey(subject: string): string | null { + return subject.match(/\[ANOUMA-([^\]]+)\]/i)?.[1] || null; +} + +export async function POST(request: Request) { + const secret = process.env.INBOUND_EMAIL_SECRET; + const supplied = request.headers.get("x-inbound-secret") || request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + if (!secret || supplied !== secret) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + let body: InboundBody; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "JSON erwartet." }, { status: 400 }); + } + + const fromRaw = body.from ?? body.sender ?? body.email; + const email = extractEmail(fromRaw); + const fromName = fromRaw && typeof fromRaw === "object" && "name" in fromRaw ? stringValue((fromRaw as { name?: unknown }).name) : ""; + const name = stringValue(body.name) || fromName || stringValue(fromRaw).replace(/<[^>]+>/, "").trim() || email; + const subject = stringValue(body.subject) || "Antwort auf deine Anfrage"; + const messageId = stringValue(body.messageId); + const text = stringValue(body.text) || stringValue(body.body) || stripHtml(stringValue(body.html)); + if (!email || !messageId || !text) return NextResponse.json({ error: "Absender, messageId und Inhalt sind erforderlich." }, { status: 400 }); + + try { + const payload = await getPayload({ config }); + if (messageId) { + const existing = await payload.find({ collection: "conversation-messages", where: { externalMessageId: { equals: messageId } }, limit: 1, overrideAccess: true }); + if (existing.docs[0]) return NextResponse.json({ ok: true, duplicate: true }); + } + + const threadKey = findThreadKey(subject); + let conversation = threadKey + ? (await payload.find({ collection: "conversations", where: { threadKey: { equals: threadKey } }, limit: 1, overrideAccess: true })).docs[0] + : undefined; + if (!conversation) { + conversation = (await payload.find({ collection: "conversations", where: { email: { equals: email }, status: { not_equals: "closed" } }, sort: "-lastMessageAt", limit: 1, overrideAccess: true })).docs[0]; + } + if (!conversation) return NextResponse.json({ error: "Keine passende Konversation gefunden." }, { status: 404 }); + + await payload.create({ + collection: "conversation-messages", + overrideAccess: true, + data: { + conversation: conversation.id, + senderType: "visitor", + senderName: name, + senderEmail: email, + body: text.slice(0, 10000), + externalMessageId: messageId, + }, + }); + await payload.update({ collection: "conversations", id: conversation.id, overrideAccess: true, data: { status: "open", lastMessageAt: new Date().toISOString() } }); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("inbound email failed", error); + return NextResponse.json({ error: "E-Mail konnte nicht übernommen werden." }, { status: 500 }); + } +} diff --git a/app/(payload)/api/group-registration/route.ts b/app/(payload)/api/group-registration/route.ts new file mode 100644 index 0000000..3d87060 --- /dev/null +++ b/app/(payload)/api/group-registration/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getPayload } from "payload"; +import config from "@payload-config"; +import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit"; +import { groupRegistrationEmail } from "@/lib/email/conversationTemplates"; +import { sendEmail } from "@/lib/email/sendBookingEmails"; + +export const dynamic = "force-dynamic"; + +const value = (v: unknown, max: number) => typeof v === "string" ? v.trim().slice(0, max) : ""; + +export async function POST(request: Request) { + if (!checkRateLimit(`group-registration:${getClientIp(request)}`, { max: 5, windowMs: 60 * 60 * 1000 })) return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 }); + let body: Record; + try { body = await request.json(); } catch { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); } + const offerSlug = value(body.offerSlug, 160); + const name = value(body.name, 160); + const email = value(body.email, 254).toLowerCase(); + const phone = value(body.phone, 80); + const message = value(body.message, 3000); + if (!offerSlug || !name || !email || !/^\S+@\S+\.\S+$/.test(email)) return NextResponse.json({ error: "Bitte fülle Name und E-Mail korrekt aus." }, { status: 400 }); + + try { + const payload = await getPayload({ config }); + const offerResult = await payload.find({ collection: "offers", where: { slug: { equals: offerSlug } }, limit: 1, overrideAccess: false }); + const offer = offerResult.docs[0]; + if (!offer || offer.bookingMode !== "group") return NextResponse.json({ error: "Dieses Gruppenangebot ist nicht verfügbar." }, { status: 404 }); + if (offer.registrationDeadline && new Date(offer.registrationDeadline) < new Date()) return NextResponse.json({ error: "Die Anmeldefrist ist bereits abgelaufen." }, { status: 409 }); + if (offer.groupCapacity) { + const count = await payload.count({ collection: "group-registrations", where: { and: [{ offer: { equals: offer.id } }, { status: { in: ["pending", "accepted"] } }] }, overrideAccess: true }); + if (count.totalDocs >= offer.groupCapacity && offer.groupRegistrationMode === "capacity") return NextResponse.json({ error: "Für dieses Angebot sind aktuell keine Plätze mehr frei." }, { status: 409 }); + } + const registration = await payload.create({ collection: "group-registrations", overrideAccess: true, data: { offer: offer.id, name, email, phone, message, status: "pending" } }); + const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true }); + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin; + await Promise.all([ + contact.email ? sendEmail(contact.email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "admin", adminUrl: `${serverUrl}/admin/collections/group-registrations/${registration.id}` }), { replyTo: email }) : Promise.resolve(), + sendEmail(email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "received" })), + ]); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("group registration failed", error); + return NextResponse.json({ error: "Die Anmeldung konnte nicht gesendet werden." }, { status: 500 }); + } +} diff --git a/collections/ConversationMessages.ts b/collections/ConversationMessages.ts new file mode 100644 index 0000000..bdc46ac --- /dev/null +++ b/collections/ConversationMessages.ts @@ -0,0 +1,102 @@ +import type { CollectionAfterChangeHook, CollectionConfig } from "payload"; +import { isAdmin } from "@/access"; +import { conversationReplyEmail } from "@/lib/email/conversationTemplates"; +import { sendEmail } from "@/lib/email/sendBookingEmails"; + +const sendAdminReply: CollectionAfterChangeHook = async ({ doc, operation, req }) => { + if (operation !== "create" || doc.senderType !== "admin") return doc; + + try { + const conversation = await req.payload.findByID({ + collection: "conversations", + id: typeof doc.conversation === "object" ? doc.conversation.id : doc.conversation, + depth: 0, + req, + }); + const adminUser = req.user?.collection === "users" ? req.user : null; + await sendEmail( + conversation.email, + conversationReplyEmail({ + name: conversation.name, + subject: conversation.subject, + body: doc.body, + senderName: doc.senderName || adminUser?.name || "Anouma", + threadKey: conversation.threadKey, + }), + { replyTo: process.env.INBOUND_REPLY_TO || "reply-to@anouma.org" }, + ); + await req.payload.update({ + collection: "conversations", + id: conversation.id, + req, + data: { status: "waiting", lastMessageAt: new Date().toISOString() }, + }); + } catch (err) { + req.payload.logger.error({ err, msg: "conversation admin reply email failed" }); + } + + return doc; +}; + +export const ConversationMessages: CollectionConfig = { + slug: "conversation-messages", + labels: { + singular: "Nachricht", + plural: "Nachrichten", + }, + admin: { + useAsTitle: "body", + defaultColumns: ["conversation", "senderType", "senderName", "createdAt"], + group: "Kommunikation", + description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus dem CMS werden automatisch per E-Mail verschickt.", + }, + access: { + read: isAdmin, + create: isAdmin, + update: isAdmin, + delete: isAdmin, + }, + timestamps: true, + hooks: { + afterChange: [sendAdminReply], + }, + fields: [ + { + name: "conversation", + type: "relationship", + relationTo: "conversations", + label: "Anfrage", + required: true, + index: true, + }, + { + name: "senderType", + type: "select", + label: "Absenderart", + required: true, + defaultValue: "admin", + options: [ + { label: "Anfragende Person", value: "visitor" }, + { label: "Anouma", value: "admin" }, + ], + admin: { description: "Für Antworten im CMS „Anouma“ auswählen. Eingehende E-Mails werden automatisch als Anfragende Person gespeichert." }, + }, + { name: "senderName", type: "text", label: "Absendername" }, + { name: "senderEmail", type: "email", label: "Absender-E-Mail" }, + { + name: "body", + type: "textarea", + label: "Nachricht", + required: true, + minLength: 1, + }, + { + name: "externalMessageId", + type: "text", + label: "Externe Nachrichten-ID", + unique: true, + index: true, + admin: { position: "sidebar", readOnly: true }, + }, + ], +}; diff --git a/collections/Conversations.ts b/collections/Conversations.ts new file mode 100644 index 0000000..f5a3464 --- /dev/null +++ b/collections/Conversations.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import type { CollectionConfig } from "payload"; +import { isAdmin, isAdminFieldLevel } from "@/access"; + +export const Conversations: CollectionConfig = { + slug: "conversations", + labels: { + singular: "Anfrage", + plural: "Anfragen", + }, + admin: { + useAsTitle: "subject", + defaultColumns: ["subject", "name", "email", "status", "lastMessageAt"], + group: "Kommunikation", + description: "Kontakt- und Terminanfragen als durchsuchbare Konversationen.", + }, + access: { + read: isAdmin, + create: () => false, + update: isAdmin, + delete: isAdmin, + }, + timestamps: true, + fields: [ + { + name: "subject", + type: "text", + label: "Betreff", + required: true, + }, + { + type: "row", + fields: [ + { name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } }, + { name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } }, + ], + }, + { + name: "type", + type: "select", + label: "Art", + required: true, + defaultValue: "contact", + options: [ + { label: "Kontakt", value: "contact" }, + { label: "Terminanfrage", value: "appointment" }, + { label: "Gruppenangebot", value: "group" }, + ], + }, + { + name: "status", + type: "select", + label: "Status", + required: true, + defaultValue: "open", + options: [ + { label: "Offen", value: "open" }, + { label: "Wartet auf Antwort", value: "waiting" }, + { label: "Erledigt", value: "closed" }, + ], + }, + { + name: "customer", + type: "relationship", + relationTo: "customers", + label: "Kundenkonto", + admin: { position: "sidebar", readOnly: true }, + }, + { + name: "threadKey", + type: "text", + label: "E-Mail-Thread-Schlüssel", + required: true, + unique: true, + index: true, + defaultValue: () => randomUUID(), + access: { read: isAdminFieldLevel, update: () => false }, + admin: { position: "sidebar", readOnly: true }, + }, + { + name: "lastMessageAt", + type: "date", + label: "Letzte Nachricht", + index: true, + admin: { position: "sidebar", readOnly: true }, + }, + ], +}; diff --git a/collections/GroupRegistrations.ts b/collections/GroupRegistrations.ts new file mode 100644 index 0000000..81bf37e --- /dev/null +++ b/collections/GroupRegistrations.ts @@ -0,0 +1,50 @@ +import type { CollectionConfig } from "payload"; +import { isAdmin } from "@/access"; + +export const GroupRegistrations: CollectionConfig = { + slug: "group-registrations", + labels: { + singular: "Gruppenanmeldung", + plural: "Gruppenanmeldungen", + }, + admin: { + useAsTitle: "name", + defaultColumns: ["name", "email", "offer", "status", "registeredAt"], + group: "Buchungen", + description: "Anmeldungen und Bewerbungen für Gruppenangebote.", + }, + access: { + read: isAdmin, + create: () => false, + update: isAdmin, + delete: isAdmin, + }, + timestamps: true, + fields: [ + { name: "offer", type: "relationship", relationTo: "offers", label: "Angebot", required: true, index: true }, + { + type: "row", + fields: [ + { name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } }, + { name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } }, + ], + }, + { name: "phone", type: "text", label: "Telefon" }, + { name: "message", type: "textarea", label: "Nachricht / Motivation" }, + { + name: "status", + type: "select", + label: "Status", + required: true, + defaultValue: "pending", + options: [ + { label: "Zu prüfen", value: "pending" }, + { label: "Angenommen", value: "accepted" }, + { label: "Warteliste", value: "waitlist" }, + { label: "Abgelehnt", value: "rejected" }, + { label: "Storniert", value: "cancelled" }, + ], + }, + { name: "registeredAt", type: "date", label: "Eingegangen am", defaultValue: () => new Date().toISOString(), admin: { position: "sidebar" } }, + ], +}; diff --git a/collections/Offers.ts b/collections/Offers.ts index 66d2b96..5b1fb1f 100644 --- a/collections/Offers.ts +++ b/collections/Offers.ts @@ -106,6 +106,72 @@ export const Offers: CollectionConfig = { description: "Aktiviert die Terminbuchung mit Kalenderauswahl auf der Angebotsseite.", }, }, + { + name: "bookingMode", + type: "select", + label: "Buchungsart", + required: true, + defaultValue: "individual", + options: [ + { label: "Einzeltermin mit Kalender", value: "individual" }, + { label: "Gruppenangebot / Anmeldung", value: "group" }, + ], + admin: { + position: "sidebar", + description: "Gruppenangebote werden über Anmeldung, feste Wiederholung oder Bewerbungsfrist organisiert.", + }, + }, + { + name: "groupRegistrationMode", + type: "select", + label: "Gruppenmodus", + defaultValue: "fixed", + options: [ + { label: "Fester Termin (wöchentlich/monatlich)", value: "fixed" }, + { label: "Plätze mit Bewerbungsfrist", value: "capacity" }, + { label: "Unverbindliche Anmeldung", value: "request" }, + ], + admin: { condition: (data) => data?.bookingMode === "group" }, + }, + { + name: "groupSchedule", + type: "group", + label: "Wiederkehrender Termin", + admin: { condition: (data) => data?.bookingMode === "group" && data?.groupRegistrationMode === "fixed" }, + fields: [ + { + name: "recurrence", + type: "select", + label: "Rhythmus", + defaultValue: "weekly", + options: [ + { label: "Jede Woche", value: "weekly" }, + { label: "Jeden Monat", value: "monthly" }, + ], + }, + { name: "weekday", type: "select", label: "Wochentag", options: [ + { label: "Montag", value: "monday" }, { label: "Dienstag", value: "tuesday" }, { label: "Mittwoch", value: "wednesday" }, + { label: "Donnerstag", value: "thursday" }, { label: "Freitag", value: "friday" }, { label: "Samstag", value: "saturday" }, { label: "Sonntag", value: "sunday" }, + ] }, + { name: "monthDay", type: "number", label: "Tag im Monat", min: 1, max: 31, admin: { condition: (data) => data?.recurrence === "monthly" } }, + { name: "startTime", type: "text", label: "Beginn", admin: { description: "z. B. 18:00 Uhr" } }, + { name: "endTime", type: "text", label: "Ende", admin: { description: "z. B. 20:00 Uhr" } }, + ], + }, + { + type: "row", + admin: { condition: (data) => data?.bookingMode === "group" }, + fields: [ + { name: "groupCapacity", type: "number", label: "Plätze", min: 1, admin: { width: "50%" } }, + { name: "registrationDeadline", type: "date", label: "Anmelde-/Bewerbungsfrist", admin: { width: "50%", date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" } } }, + ], + }, + { + name: "groupInfo", + type: "textarea", + label: "Hinweis zum Gruppenangebot", + admin: { condition: (data) => data?.bookingMode === "group", description: "Wird auf der Angebotsseite oberhalb des Anmeldeformulars angezeigt." }, + }, { type: "row", fields: [ diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index d990bdc..9d40230 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -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) { + async function handleSubmit(e: FormEvent) { 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} - {sent && ( + {state.sent && (

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

)} + {state.error &&

{state.error}

} ); } diff --git a/components/GroupRegistrationForm.tsx b/components/GroupRegistrationForm.tsx new file mode 100644 index 0000000..057f9e5 --- /dev/null +++ b/components/GroupRegistrationForm.tsx @@ -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) { + 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

Danke, deine Anmeldung ist angekommen. Anna meldet sich, sobald sie die Plätze geprüft hat.

; + + return ( +
+ + + + + {state.error &&

{state.error}

} + + + ); +} diff --git a/components/admin/AdminConversationsView.tsx b/components/admin/AdminConversationsView.tsx new file mode 100644 index 0000000..29461d2 --- /dev/null +++ b/components/admin/AdminConversationsView.tsx @@ -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 = { open: "Offen", waiting: "Wartet auf Antwort", closed: "Erledigt" }; + +export function AdminConversationsView() { + const [conversations, setConversations] = useState([]); + const [selected, setSelected] = useState(null); + const [messages, setMessages] = useState([]); + 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 ( +
+

Anfragen & Nachrichten

+

Kontakt- und Terminanfragen zentral bearbeiten. Antworten werden automatisch per E-Mail verschickt.

+ {error &&

{error}

} +
+
+ {loading &&

Anfragen werden geladen …

} + {!loading && conversations.length === 0 &&

Noch keine Anfragen.

} + {conversations.map((conversation) => ( + + ))} +
+
+ {!selected ?

Wähle links eine Anfrage aus.

: <> +

{selected.subject}

{selected.name} · {selected.email}

+
+ {messages.map((message) =>
{message.senderType === "admin" ? "Anouma" : message.senderName || selected.name} · {new Date(message.createdAt).toLocaleString("de-DE")}
{message.body}
)} +
+