feat: add CMS inquiries and group bookings
This commit is contained in:
@@ -51,6 +51,13 @@ SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM="ANOUMA <support@anouma.org>"
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<string, string> = { 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) {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{offer.bookable ? (
|
||||
{offer.bookingMode === "group" ? (
|
||||
<Section tone="cream">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h2 className="mb-3 text-center font-serif text-3xl font-medium text-anouma-plum">Für die Gruppe anmelden</h2>
|
||||
{offer.groupInfo && <p className="mx-auto mb-6 max-w-xl text-center text-base leading-relaxed text-anouma-plum">{offer.groupInfo}</p>}
|
||||
{offer.groupSchedule && (
|
||||
<div className="mb-8 rounded-2xl bg-white p-5 text-sm text-anouma-plum">
|
||||
<p className="font-medium">Fester Termin</p>
|
||||
<p className="mt-1">{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}` : ""}</p>
|
||||
</div>
|
||||
)}
|
||||
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
|
||||
{offer.registrationDeadline && <p className="mb-6 text-sm text-anouma-plum/75">Anmeldung möglich bis {new Date(offer.registrationDeadline).toLocaleDateString("de-DE")}</p>}
|
||||
<GroupRegistrationForm offerSlug={offer.slug!} />
|
||||
</div>
|
||||
</Section>
|
||||
) : offer.bookable ? (
|
||||
<Section tone="cream">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="mb-6 text-center font-serif text-3xl font-medium text-anouma-plum">Termin auswählen</h2>
|
||||
|
||||
@@ -32,9 +32,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
<Section tone="plain">
|
||||
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
||||
<Reveal>
|
||||
<ContactForm toEmail={contact.email} />
|
||||
<ContactForm subjectPrefix={isAppointment ? "Terminanfrage" : "Allgemeine Anfrage"} type={isAppointment ? "appointment" : "contact"} submitLabel={isAppointment ? "Terminanfrage senden" : "Nachricht senden"} />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="space-y-8">
|
||||
@@ -70,12 +72,9 @@ export default async function KontaktPage() {
|
||||
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
E-Mail
|
||||
</span>
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-lg text-anouma-plum hover:text-anouma-mauve-dark"
|
||||
>
|
||||
<span className="text-lg text-anouma-plum">
|
||||
{contact.email}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{contact.phone && (
|
||||
<p>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
}
|
||||
|
||||
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" }]}
|
||||
/>
|
||||
|
||||
<Section tone="cream">
|
||||
<div className="mx-auto grid max-w-4xl gap-5 md:grid-cols-2">
|
||||
<a href="#freie-termine" className="rounded-3xl bg-white p-7 transition-transform hover:-translate-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-dustyrose">Direkt buchen</p>
|
||||
<h2 className="mt-3 font-serif text-2xl font-medium text-anouma-plum">Freien Termin auswählen</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum/80">Wähle ein Angebot, einen freien Tag und eine passende Uhrzeit im Kalender.</p>
|
||||
<span className="mt-5 inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white">Zum Kalender</span>
|
||||
</a>
|
||||
<a href="/kontakt?subject=Terminanfrage" className="rounded-3xl bg-anouma-plum p-7 transition-transform hover:-translate-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-cream-light/75">Persönlich abstimmen</p>
|
||||
<h2 className="mt-3 font-serif text-2xl font-medium text-white">Anfrage stellen</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/80">Wenn du keinen freien Kalendertermin brauchst oder etwas Besonderes besprechen möchtest.</p>
|
||||
<span className="mt-5 inline-flex rounded-full bg-white px-5 py-2.5 text-sm font-medium text-anouma-plum">Zur Kontaktseite</span>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{bookableOffers.length > 0 && (
|
||||
<Section tone="plain">
|
||||
<Section tone="plain" id="freie-termine">
|
||||
<SectionHeading eyebrow="Angebot auswählen" title="Wofür möchtest du einen Termin?" />
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bookableOffers.map((offer, i) => (
|
||||
@@ -105,14 +122,11 @@ export default async function TerminBuchenPage() {
|
||||
/>
|
||||
<div className="mt-10">
|
||||
<ContactForm
|
||||
toEmail={contact.email}
|
||||
subjectPrefix="Terminanfrage über anouma.org"
|
||||
type="appointment"
|
||||
submitLabel="Terminanfrage senden"
|
||||
/>
|
||||
</div>
|
||||
{booking.formNote && (
|
||||
<p className="mt-8 text-sm leading-relaxed text-anouma-plum/80">{booking.formNote}</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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" } },
|
||||
],
|
||||
};
|
||||
@@ -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: [
|
||||
|
||||
+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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { escapeHtml, paragraph, renderButton, renderEmailShell } from "./layout";
|
||||
|
||||
export function newConversationAdminEmail(args: {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
adminUrl: string;
|
||||
threadKey: string;
|
||||
}): { subject: string; html: string } {
|
||||
return {
|
||||
subject: `Neue Anfrage: ${args.subject} [ANOUMA-${args.threadKey}]`,
|
||||
html: renderEmailShell({
|
||||
bodyHtml: [
|
||||
paragraph("Eine neue Anfrage ist eingegangen.", { italic: true }),
|
||||
`<p style="margin:0 0 20px;color:#66505f;font-size:14px;"><strong>${escapeHtml(args.name)}</strong> · ${escapeHtml(args.email)}<br /><strong>${escapeHtml(args.subject)}</strong></p>`,
|
||||
`<div style="padding:16px 20px;background:#fbf6f1;border-radius:16px;color:#66505f;font-size:15px;line-height:1.6;white-space:pre-wrap;">${escapeHtml(args.body)}</div>`,
|
||||
renderButton("Anfrage im CMS öffnen", args.adminUrl),
|
||||
].join("\n"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function conversationReplyEmail(args: {
|
||||
name: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
senderName: string;
|
||||
threadKey: string;
|
||||
}): { subject: string; html: string } {
|
||||
return {
|
||||
subject: `Re: ${args.subject} [ANOUMA-${args.threadKey}]`,
|
||||
html: renderEmailShell({
|
||||
bodyHtml: [
|
||||
paragraph(`Hallo ${args.name},`),
|
||||
paragraph(`${args.senderName} hat auf deine Anfrage geantwortet.`, { italic: true }),
|
||||
`<div style="padding:16px 20px;background:#f2e2d6;border-radius:16px;color:#66505f;font-size:15px;line-height:1.6;white-space:pre-wrap;">${escapeHtml(args.body)}</div>`,
|
||||
paragraph("Du kannst einfach auf diese E-Mail antworten. Deine Antwort wird automatisch derselben Anfrage zugeordnet.", { small: true }),
|
||||
].join("\n"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function groupRegistrationEmail(args: {
|
||||
name: string;
|
||||
offerTitle: string;
|
||||
status: "received" | "admin";
|
||||
adminUrl?: string;
|
||||
}): { subject: string; html: string } {
|
||||
const isAdminMail = args.status === "admin";
|
||||
return {
|
||||
subject: isAdminMail ? `Neue Gruppenanmeldung: ${args.offerTitle}` : `Deine Anmeldung bei ANOUMA: ${args.offerTitle}`,
|
||||
html: renderEmailShell({
|
||||
bodyHtml: [
|
||||
paragraph(isAdminMail ? "Eine neue Gruppenanmeldung ist eingegangen." : `Hallo ${args.name},`),
|
||||
paragraph(isAdminMail ? `${args.name} hat sich für „${args.offerTitle}“ angemeldet.` : `Deine Anmeldung für „${args.offerTitle}“ wurde übermittelt. Anna meldet sich, sobald sie sie geprüft hat.`, { italic: true }),
|
||||
...(isAdminMail && args.adminUrl ? [renderButton("Anmeldung im CMS öffnen", args.adminUrl)] : []),
|
||||
].join("\n"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { getMailFrom, getMailTransport } from "./transport";
|
||||
|
||||
export async function sendEmail(to: string, template: { subject: string; html: string }) {
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
template: { subject: string; html: string },
|
||||
options: { replyTo?: string } = {},
|
||||
) {
|
||||
const transport = getMailTransport();
|
||||
await transport.sendMail({ from: getMailFrom(), to, subject: template.subject, html: template.html });
|
||||
await transport.sendMail({
|
||||
from: getMailFrom(),
|
||||
to,
|
||||
subject: template.subject,
|
||||
html: template.html,
|
||||
...(options.replyTo ? { replyTo: options.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
/** Schema for CMS conversations, inbound replies and configurable group offers. */
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_offers_booking_mode" AS ENUM('individual', 'group');
|
||||
CREATE TYPE "public"."enum_offers_group_registration_mode" AS ENUM('fixed', 'capacity', 'request');
|
||||
CREATE TYPE "public"."enum_offers_group_schedule_recurrence" AS ENUM('weekly', 'monthly');
|
||||
CREATE TYPE "public"."enum_offers_group_schedule_weekday" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
|
||||
CREATE TYPE "public"."enum_conversations_type" AS ENUM('contact', 'appointment', 'group');
|
||||
CREATE TYPE "public"."enum_conversations_status" AS ENUM('open', 'waiting', 'closed');
|
||||
CREATE TYPE "public"."enum_conversation_messages_sender_type" AS ENUM('visitor', 'admin');
|
||||
CREATE TYPE "public"."enum_group_registrations_status" AS ENUM('pending', 'accepted', 'waitlist', 'rejected', 'cancelled');
|
||||
|
||||
ALTER TABLE "offers" ADD COLUMN "booking_mode" "enum_offers_booking_mode" DEFAULT 'individual' NOT NULL;
|
||||
ALTER TABLE "offers" ADD COLUMN "group_registration_mode" "enum_offers_group_registration_mode" DEFAULT 'fixed';
|
||||
ALTER TABLE "offers" ADD COLUMN "group_schedule_recurrence" "enum_offers_group_schedule_recurrence";
|
||||
ALTER TABLE "offers" ADD COLUMN "group_schedule_weekday" "enum_offers_group_schedule_weekday";
|
||||
ALTER TABLE "offers" ADD COLUMN "group_schedule_month_day" numeric;
|
||||
ALTER TABLE "offers" ADD COLUMN "group_schedule_start_time" varchar;
|
||||
ALTER TABLE "offers" ADD COLUMN "group_schedule_end_time" varchar;
|
||||
ALTER TABLE "offers" ADD COLUMN "group_capacity" numeric;
|
||||
ALTER TABLE "offers" ADD COLUMN "registration_deadline" timestamp(3) with time zone;
|
||||
ALTER TABLE "offers" ADD COLUMN "group_info" varchar;
|
||||
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_booking_mode" "enum_offers_booking_mode" DEFAULT 'individual';
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_registration_mode" "enum_offers_group_registration_mode" DEFAULT 'fixed';
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_schedule_recurrence" "enum_offers_group_schedule_recurrence";
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_schedule_weekday" "enum_offers_group_schedule_weekday";
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_schedule_month_day" numeric;
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_schedule_start_time" varchar;
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_schedule_end_time" varchar;
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_capacity" numeric;
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_registration_deadline" timestamp(3) with time zone;
|
||||
ALTER TABLE "_offers_v" ADD COLUMN "version_group_info" varchar;
|
||||
|
||||
CREATE TABLE "conversations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"subject" varchar NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"type" "enum_conversations_type" DEFAULT 'contact' NOT NULL,
|
||||
"status" "enum_conversations_status" DEFAULT 'open' NOT NULL,
|
||||
"customer_id" integer,
|
||||
"thread_key" varchar NOT NULL,
|
||||
"last_message_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE TABLE "conversation_messages" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"conversation_id" integer NOT NULL,
|
||||
"sender_type" "enum_conversation_messages_sender_type" DEFAULT 'admin' NOT NULL,
|
||||
"sender_name" varchar,
|
||||
"sender_email" varchar,
|
||||
"body" varchar NOT NULL,
|
||||
"external_message_id" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE TABLE "group_registrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"offer_id" integer NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"phone" varchar,
|
||||
"message" varchar,
|
||||
"status" "enum_group_registrations_status" DEFAULT 'pending' NOT NULL,
|
||||
"registered_at" timestamp(3) with time zone DEFAULT now(),
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "conversations_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "conversation_messages_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "group_registrations_id" integer;
|
||||
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "conversation_messages" ADD CONSTRAINT "conversation_messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "group_registrations" ADD CONSTRAINT "group_registrations_offer_id_offers_id_fk" FOREIGN KEY ("offer_id") REFERENCES "public"."offers"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_conversations_fk" FOREIGN KEY ("conversations_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_conversation_messages_fk" FOREIGN KEY ("conversation_messages_id") REFERENCES "public"."conversation_messages"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_group_registrations_fk" FOREIGN KEY ("group_registrations_id") REFERENCES "public"."group_registrations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
|
||||
CREATE UNIQUE INDEX "conversations_thread_key_idx" ON "conversations" USING btree ("thread_key");
|
||||
CREATE INDEX "conversations_customer_idx" ON "conversations" USING btree ("customer_id");
|
||||
CREATE INDEX "conversations_last_message_at_idx" ON "conversations" USING btree ("last_message_at");
|
||||
CREATE INDEX "conversations_updated_at_idx" ON "conversations" USING btree ("updated_at");
|
||||
CREATE INDEX "conversations_created_at_idx" ON "conversations" USING btree ("created_at");
|
||||
CREATE INDEX "conversation_messages_conversation_idx" ON "conversation_messages" USING btree ("conversation_id");
|
||||
CREATE UNIQUE INDEX "conversation_messages_external_message_id_idx" ON "conversation_messages" USING btree ("external_message_id");
|
||||
CREATE INDEX "conversation_messages_updated_at_idx" ON "conversation_messages" USING btree ("updated_at");
|
||||
CREATE INDEX "conversation_messages_created_at_idx" ON "conversation_messages" USING btree ("created_at");
|
||||
CREATE INDEX "group_registrations_offer_idx" ON "group_registrations" USING btree ("offer_id");
|
||||
CREATE INDEX "group_registrations_updated_at_idx" ON "group_registrations" USING btree ("updated_at");
|
||||
CREATE INDEX "group_registrations_created_at_idx" ON "group_registrations" USING btree ("created_at");
|
||||
CREATE INDEX "payload_locked_documents_rels_conversations_id_idx" ON "payload_locked_documents_rels" USING btree ("conversations_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_conversation_messages_id_idx" ON "payload_locked_documents_rels" USING btree ("conversation_messages_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_group_registrations_id_idx" ON "payload_locked_documents_rels" USING btree ("group_registrations_id");
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_conversations_fk";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_conversation_messages_fk";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_group_registrations_fk";
|
||||
ALTER TABLE "conversations" DROP CONSTRAINT "conversations_customer_id_customers_id_fk";
|
||||
ALTER TABLE "conversation_messages" DROP CONSTRAINT "conversation_messages_conversation_id_conversations_id_fk";
|
||||
ALTER TABLE "group_registrations" DROP CONSTRAINT "group_registrations_offer_id_offers_id_fk";
|
||||
DROP TABLE "conversation_messages" CASCADE;
|
||||
DROP TABLE "conversations" CASCADE;
|
||||
DROP TABLE "group_registrations" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "conversations_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "conversation_messages_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "group_registrations_id";
|
||||
ALTER TABLE "offers" DROP COLUMN "booking_mode";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_registration_mode";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_schedule_recurrence";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_schedule_weekday";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_schedule_month_day";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_schedule_start_time";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_schedule_end_time";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_capacity";
|
||||
ALTER TABLE "offers" DROP COLUMN "registration_deadline";
|
||||
ALTER TABLE "offers" DROP COLUMN "group_info";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_booking_mode";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_registration_mode";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_schedule_recurrence";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_schedule_weekday";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_schedule_month_day";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_schedule_start_time";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_schedule_end_time";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_capacity";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_registration_deadline";
|
||||
ALTER TABLE "_offers_v" DROP COLUMN "version_group_info";
|
||||
DROP TYPE "public"."enum_offers_booking_mode";
|
||||
DROP TYPE "public"."enum_offers_group_registration_mode";
|
||||
DROP TYPE "public"."enum_offers_group_schedule_recurrence";
|
||||
DROP TYPE "public"."enum_offers_group_schedule_weekday";
|
||||
DROP TYPE "public"."enum_conversations_type";
|
||||
DROP TYPE "public"."enum_conversations_status";
|
||||
DROP TYPE "public"."enum_conversation_messages_sender_type";
|
||||
DROP TYPE "public"."enum_group_registrations_status";
|
||||
`);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as migration_20260826_004425 from './20260826_004425';
|
||||
import * as migration_20260916_192252_cookie_settings from './20260916_192252_cookie_settings';
|
||||
import * as migration_20260917_090000_contact_details from './20260917_090000_contact_details';
|
||||
import * as migration_20260917_154000_communication_and_groups from './20260917_154000_communication_and_groups';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
@@ -18,4 +19,9 @@ export const migrations = [
|
||||
down: migration_20260917_090000_contact_details.down,
|
||||
name: '20260917_090000_contact_details'
|
||||
},
|
||||
{
|
||||
up: migration_20260917_154000_communication_and_groups.up,
|
||||
down: migration_20260917_154000_communication_and_groups.down,
|
||||
name: '20260917_154000_communication_and_groups'
|
||||
},
|
||||
];
|
||||
|
||||
@@ -78,6 +78,9 @@ export interface Config {
|
||||
availability: Availability;
|
||||
'availability-overrides': AvailabilityOverride;
|
||||
'booking-requests': BookingRequest;
|
||||
conversations: Conversation;
|
||||
'conversation-messages': ConversationMessage;
|
||||
'group-registrations': GroupRegistration;
|
||||
redirects: Redirect;
|
||||
'analytics-events': AnalyticsEvent;
|
||||
'analytics-daily-stats': AnalyticsDailyStat;
|
||||
@@ -98,6 +101,9 @@ export interface Config {
|
||||
availability: AvailabilitySelect<false> | AvailabilitySelect<true>;
|
||||
'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>;
|
||||
'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>;
|
||||
conversations: ConversationsSelect<false> | ConversationsSelect<true>;
|
||||
'conversation-messages': ConversationMessagesSelect<false> | ConversationMessagesSelect<true>;
|
||||
'group-registrations': GroupRegistrationsSelect<false> | GroupRegistrationsSelect<true>;
|
||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||
'analytics-events': AnalyticsEventsSelect<false> | AnalyticsEventsSelect<true>;
|
||||
'analytics-daily-stats': AnalyticsDailyStatsSelect<false> | AnalyticsDailyStatsSelect<true>;
|
||||
@@ -472,6 +478,30 @@ export interface Offer {
|
||||
* Aktiviert die Terminbuchung mit Kalenderauswahl auf der Angebotsseite.
|
||||
*/
|
||||
bookable?: boolean | null;
|
||||
/**
|
||||
* Gruppenangebote werden über Anmeldung, feste Wiederholung oder Bewerbungsfrist organisiert.
|
||||
*/
|
||||
bookingMode: 'individual' | 'group';
|
||||
groupRegistrationMode?: ('fixed' | 'capacity' | 'request') | null;
|
||||
groupSchedule?: {
|
||||
recurrence?: ('weekly' | 'monthly') | null;
|
||||
weekday?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday') | null;
|
||||
monthDay?: number | null;
|
||||
/**
|
||||
* z. B. 18:00 Uhr
|
||||
*/
|
||||
startTime?: string | null;
|
||||
/**
|
||||
* z. B. 20:00 Uhr
|
||||
*/
|
||||
endTime?: string | null;
|
||||
};
|
||||
groupCapacity?: number | null;
|
||||
registrationDeadline?: string | null;
|
||||
/**
|
||||
* Wird auf der Angebotsseite oberhalb des Anmeldeformulars angezeigt.
|
||||
*/
|
||||
groupInfo?: string | null;
|
||||
/**
|
||||
* z. B. „80 €“ oder „auf Anfrage“.
|
||||
*/
|
||||
@@ -602,6 +632,63 @@ export interface AvailabilityOverride {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Kontakt- und Terminanfragen als durchsuchbare Konversationen.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "conversations".
|
||||
*/
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
subject: string;
|
||||
name: string;
|
||||
email: string;
|
||||
type: 'contact' | 'appointment' | 'group';
|
||||
status: 'open' | 'waiting' | 'closed';
|
||||
customer?: (number | null) | Customer;
|
||||
threadKey: string;
|
||||
lastMessageAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus dem CMS werden automatisch per E-Mail verschickt.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "conversation-messages".
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
id: number;
|
||||
conversation: number | Conversation;
|
||||
/**
|
||||
* Für Antworten im CMS „Anouma“ auswählen. Eingehende E-Mails werden automatisch als Anfragende Person gespeichert.
|
||||
*/
|
||||
senderType: 'visitor' | 'admin';
|
||||
senderName?: string | null;
|
||||
senderEmail?: string | null;
|
||||
body: string;
|
||||
externalMessageId?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Anmeldungen und Bewerbungen für Gruppenangebote.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "group-registrations".
|
||||
*/
|
||||
export interface GroupRegistration {
|
||||
id: number;
|
||||
offer: number | Offer;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
message?: string | null;
|
||||
status: 'pending' | 'accepted' | 'waitlist' | 'rejected' | 'cancelled';
|
||||
registeredAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Leitet eine alte URL (z. B. nach einer Slug-Änderung bei Angeboten, Terminen oder Aktuelles) dauerhaft auf eine neue weiter.
|
||||
*
|
||||
@@ -770,6 +857,18 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'booking-requests';
|
||||
value: number | BookingRequest;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'conversations';
|
||||
value: number | Conversation;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'conversation-messages';
|
||||
value: number | ConversationMessage;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'group-registrations';
|
||||
value: number | GroupRegistration;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'redirects';
|
||||
value: number | Redirect;
|
||||
@@ -1017,6 +1116,20 @@ export interface OffersSelect<T extends boolean = true> {
|
||||
order?: T;
|
||||
visibility?: T;
|
||||
bookable?: T;
|
||||
bookingMode?: T;
|
||||
groupRegistrationMode?: T;
|
||||
groupSchedule?:
|
||||
| T
|
||||
| {
|
||||
recurrence?: T;
|
||||
weekday?: T;
|
||||
monthDay?: T;
|
||||
startTime?: T;
|
||||
endTime?: T;
|
||||
};
|
||||
groupCapacity?: T;
|
||||
registrationDeadline?: T;
|
||||
groupInfo?: T;
|
||||
price?: T;
|
||||
durationMinutes?: T;
|
||||
seo?:
|
||||
@@ -1096,6 +1209,51 @@ export interface BookingRequestsSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "conversations_select".
|
||||
*/
|
||||
export interface ConversationsSelect<T extends boolean = true> {
|
||||
subject?: T;
|
||||
name?: T;
|
||||
email?: T;
|
||||
type?: T;
|
||||
status?: T;
|
||||
customer?: T;
|
||||
threadKey?: T;
|
||||
lastMessageAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "conversation-messages_select".
|
||||
*/
|
||||
export interface ConversationMessagesSelect<T extends boolean = true> {
|
||||
conversation?: T;
|
||||
senderType?: T;
|
||||
senderName?: T;
|
||||
senderEmail?: T;
|
||||
body?: T;
|
||||
externalMessageId?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "group-registrations_select".
|
||||
*/
|
||||
export interface GroupRegistrationsSelect<T extends boolean = true> {
|
||||
offer?: T;
|
||||
name?: T;
|
||||
email?: T;
|
||||
phone?: T;
|
||||
message?: T;
|
||||
status?: T;
|
||||
registeredAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "redirects_select".
|
||||
|
||||
@@ -15,6 +15,9 @@ import { Customers } from "@/collections/Customers";
|
||||
import { Availability } from "@/collections/Availability";
|
||||
import { AvailabilityOverrides } from "@/collections/AvailabilityOverrides";
|
||||
import { BookingRequests } from "@/collections/BookingRequests";
|
||||
import { Conversations } from "@/collections/Conversations";
|
||||
import { ConversationMessages } from "@/collections/ConversationMessages";
|
||||
import { GroupRegistrations } from "@/collections/GroupRegistrations";
|
||||
import { Redirects } from "@/collections/Redirects";
|
||||
import { AnalyticsEvents } from "@/collections/AnalyticsEvents";
|
||||
import { AnalyticsDailyStats } from "@/collections/AnalyticsDailyStats";
|
||||
@@ -49,6 +52,7 @@ export default buildConfig({
|
||||
afterNavLinks: [
|
||||
"/components/admin/CalendarNavLink#CalendarNavLink",
|
||||
"/components/admin/AnalyticsNavLink#AnalyticsNavLink",
|
||||
"/components/admin/ConversationsNavLink#ConversationsNavLink",
|
||||
],
|
||||
views: {
|
||||
kalender: {
|
||||
@@ -63,6 +67,12 @@ export default buildConfig({
|
||||
exact: true,
|
||||
meta: { title: "Analytics" },
|
||||
},
|
||||
anfragen: {
|
||||
Component: "/components/admin/AdminConversationsView#AdminConversationsView",
|
||||
path: "/anfragen",
|
||||
exact: true,
|
||||
meta: { title: "Anfragen" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -77,6 +87,9 @@ export default buildConfig({
|
||||
Availability,
|
||||
AvailabilityOverrides,
|
||||
BookingRequests,
|
||||
Conversations,
|
||||
ConversationMessages,
|
||||
GroupRegistrations,
|
||||
Redirects,
|
||||
AnalyticsEvents,
|
||||
AnalyticsDailyStats,
|
||||
|
||||
+13
-2
@@ -29,6 +29,7 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "prozessbegleitung",
|
||||
title: "Prozessbegleitung",
|
||||
category: "prozessbegleitung" as const,
|
||||
bookingMode: "individual" as const,
|
||||
order: 1,
|
||||
shortDescription: "Persönliche Begleitung in Lebensphasen, Veränderungen und inneren Prozessen.",
|
||||
description: lexicalFromSections(prozessbegleitung.intro, prozessbegleitung.sections),
|
||||
@@ -37,6 +38,7 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "doula-begleitung",
|
||||
title: "Doula-Begleitung",
|
||||
category: "doula-begleitung" as const,
|
||||
bookingMode: "individual" as const,
|
||||
order: 2,
|
||||
shortDescription: "Begleitung vor, während und nach der Geburt.",
|
||||
description: lexicalFromSections(doula.intro, doula.sections),
|
||||
@@ -45,6 +47,8 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "erdenkinder",
|
||||
title: "Erdenkinder",
|
||||
category: "kindergruppen" as const,
|
||||
bookingMode: "group" as const,
|
||||
groupRegistrationMode: "fixed" as const,
|
||||
order: 3,
|
||||
shortDescription: "Naturverbunden. Kreativ. Frei. Für Kinder.",
|
||||
description: lexicalFromParagraphs(erdenkinder.paragraphs),
|
||||
@@ -53,6 +57,8 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "maedchenkreis",
|
||||
title: "Mädchenkreis",
|
||||
category: "kindergruppen" as const,
|
||||
bookingMode: "group" as const,
|
||||
groupRegistrationMode: "capacity" as const,
|
||||
order: 4,
|
||||
shortDescription: "Stärkung. Vertrauen. Gemeinschaft. Für Mädchen.",
|
||||
description: lexicalFromParagraphs(maedchenkreis.paragraphs),
|
||||
@@ -61,6 +67,8 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "singen-im-kreis",
|
||||
title: "Singen im Kreis",
|
||||
category: "singkreise" as const,
|
||||
bookingMode: "group" as const,
|
||||
groupRegistrationMode: "fixed" as const,
|
||||
order: 5,
|
||||
shortDescription: "Gemeinsam singen. Verbunden sein.",
|
||||
description: lexicalFromParagraphs(singenImKreis.paragraphs),
|
||||
@@ -69,6 +77,8 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "singen-fuer-schwangere",
|
||||
title: "Singen für Schwangere",
|
||||
category: "singkreise" as const,
|
||||
bookingMode: "group" as const,
|
||||
groupRegistrationMode: "fixed" as const,
|
||||
order: 6,
|
||||
shortDescription: "Lieder, die tragen. Für dich und dein Baby.",
|
||||
description: lexicalFromParagraphs(singenFuerSchwangere.paragraphs),
|
||||
@@ -77,6 +87,8 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "mama-baby-singkreis",
|
||||
title: "Singen für Mamas mit Baby",
|
||||
category: "singkreise" as const,
|
||||
bookingMode: "group" as const,
|
||||
groupRegistrationMode: "fixed" as const,
|
||||
order: 7,
|
||||
shortDescription: "Zeit für dich und dein Baby. Singen. Austauschen. Auftanken.",
|
||||
description: lexicalFromParagraphs(mamaBabySingkreis.paragraphs),
|
||||
@@ -90,6 +102,7 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
slug: "einzelbegleitung",
|
||||
title: "Einzelbegleitung",
|
||||
category: "prozessbegleitung" as const,
|
||||
bookingMode: "individual" as const,
|
||||
order: 8,
|
||||
visibility: "private" as const,
|
||||
bookable: true,
|
||||
@@ -226,8 +239,6 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
text: "Passt es für euch beide, vereinbaren wir gemeinsam die weiteren Termine.",
|
||||
},
|
||||
],
|
||||
formNote:
|
||||
"Aktuell öffnet dieses Formular dein E-Mail-Programm. Sobald ein Buchungstool (z. B. für Online-Terminvergabe) ausgewählt ist, kann es hier eingebunden werden.",
|
||||
},
|
||||
});
|
||||
console.log(" ↺ Global „Termin buchen“ gesetzt");
|
||||
|
||||
Reference in New Issue
Block a user