Initial commit: ANOUMA website with Payload CMS, WebRTC meetings, and booking system
- Next.js 16 App Router site with the ANOUMA design system - Payload CMS (PostgreSQL) for offers, events, posts and page content - WebRTC video-call system with custom signaling server - SMTP email reminders and booking-request notifications - Customer accounts, calendar-based availability, and booking workflow
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const WEEKDAYS = [
|
||||
{ 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" },
|
||||
] as const;
|
||||
|
||||
export const Availability: CollectionConfig = {
|
||||
slug: "availability",
|
||||
labels: {
|
||||
singular: "Verfügbarkeit",
|
||||
plural: "Verfügbarkeiten",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "weekday",
|
||||
defaultColumns: ["weekday", "startTime", "endTime", "active"],
|
||||
group: "Buchungen",
|
||||
description: "Wiederkehrende wöchentliche Arbeitszeiten für Buchungsanfragen. Kein Eintrag = an diesem Tag nicht verfügbar.",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "weekday",
|
||||
type: "select",
|
||||
label: "Wochentag",
|
||||
required: true,
|
||||
options: [...WEEKDAYS],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Start",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Ende",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "active",
|
||||
type: "checkbox",
|
||||
label: "Aktiv",
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: "Deaktivieren statt löschen, um eine Zeitspanne vorübergehend auszusetzen.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const AvailabilityOverrides: CollectionConfig = {
|
||||
slug: "availability-overrides",
|
||||
labels: {
|
||||
singular: "Ausnahme",
|
||||
plural: "Ausnahmen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "date",
|
||||
defaultColumns: ["date", "type", "reason"],
|
||||
group: "Buchungen",
|
||||
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Datum",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" } },
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
label: "Art",
|
||||
required: true,
|
||||
defaultValue: "unavailable",
|
||||
options: [
|
||||
{ label: "Nicht verfügbar (ganzer Tag)", value: "unavailable" },
|
||||
{ label: "Abweichende Uhrzeiten", value: "custom-hours" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { condition: (data) => data?.type === "custom-hours" },
|
||||
fields: [
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Start",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Ende",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reason",
|
||||
type: "select",
|
||||
label: "Grund",
|
||||
options: [
|
||||
{ label: "Urlaub", value: "urlaub" },
|
||||
{ label: "Feiertag", value: "feiertag" },
|
||||
{ label: "Krankheit", value: "krankheit" },
|
||||
{ label: "Sonderöffnungszeit", value: "sonderoeffnungszeit" },
|
||||
{ label: "Sonstiges", value: "sonstiges" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,403 @@
|
||||
import { APIError } from "payload";
|
||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
||||
import {
|
||||
alternativeProposedEmail,
|
||||
bookingCancelledAdminEmail,
|
||||
bookingConfirmedEmail,
|
||||
bookingRejectedEmail,
|
||||
bookingRequestReceivedEmail,
|
||||
newBookingRequestAdminEmail,
|
||||
} from "@/lib/email/bookingTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
import type { Customer, Event, Offer } from "@/payload-types";
|
||||
|
||||
export const APPOINTMENT_TYPES = [
|
||||
{ label: "Vor Ort", value: "onsite" },
|
||||
{ label: "Online", value: "online" },
|
||||
] as const;
|
||||
|
||||
export const BOOKING_STATUSES = [
|
||||
{ label: "Ausstehend", value: "pending" },
|
||||
{ label: "Bestätigt", value: "confirmed" },
|
||||
{ label: "Abgelehnt", value: "rejected" },
|
||||
{ label: "Storniert", value: "cancelled" },
|
||||
] as const;
|
||||
|
||||
function dateKey(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
const appointmentTypeLabel = (value: string) =>
|
||||
APPOINTMENT_TYPES.find((t) => t.value === value)?.label ?? value;
|
||||
|
||||
async function notifyAdmins(
|
||||
req: Parameters<CollectionAfterChangeHook>[0]["req"],
|
||||
template: { subject: string; html: string },
|
||||
) {
|
||||
const { docs: admins } = await req.payload.find({ collection: "users", limit: 50, req });
|
||||
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
|
||||
}
|
||||
|
||||
// Runs whenever a booking is about to become "confirmed": locks the day
|
||||
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
|
||||
// inside that same lock/transaction, and — for online appointments — creates
|
||||
// the linked video-call Event (reusing the existing meeting/password system)
|
||||
// before the booking itself is written.
|
||||
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req, operation }) => {
|
||||
if (operation !== "update" || data.status !== "confirmed" || originalDoc?.status === "confirmed") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const date = data.date ?? originalDoc.date;
|
||||
const startTime = data.startTime ?? originalDoc.startTime;
|
||||
const endTime = data.endTime ?? originalDoc.endTime;
|
||||
const appointmentType = data.appointmentType ?? originalDoc.appointmentType;
|
||||
|
||||
await acquireBookingDayLock(req, dateKey(date));
|
||||
|
||||
const { docs: sameDayConfirmed } = await req.payload.find({
|
||||
collection: "booking-requests",
|
||||
where: {
|
||||
and: [
|
||||
{ status: { equals: "confirmed" } },
|
||||
{ id: { not_equals: originalDoc.id } },
|
||||
{ date: { greater_than_equal: new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString() } },
|
||||
{ date: { less_than_equal: new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString() } },
|
||||
],
|
||||
},
|
||||
req,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
const newStart = new Date(startTime).getTime();
|
||||
const newEnd = new Date(endTime).getTime();
|
||||
const conflict = sameDayConfirmed.some((doc) => {
|
||||
const s = new Date(doc.startTime).getTime();
|
||||
const e = new Date(doc.endTime).getTime();
|
||||
return newStart < e && s < newEnd;
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
throw new APIError(
|
||||
"Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
|
||||
409,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) {
|
||||
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer;
|
||||
const event = (await req.payload.create({
|
||||
collection: "events",
|
||||
req,
|
||||
data: {
|
||||
title: offer.title,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
category: "sonstiges",
|
||||
isOnline: true, // Events' own beforeChange hook auto-generates the password.
|
||||
isPrivateBooking: true,
|
||||
bookingRequest: originalDoc.id,
|
||||
},
|
||||
// Created as a draft so it never appears in public /termine listings
|
||||
// (see lib/payload/content.ts) — it's still reachable via its direct
|
||||
// join link, which is exactly what a private 1:1 booking needs.
|
||||
draft: true,
|
||||
})) as Event;
|
||||
data.linkedEvent = event.id;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, operation, req }) => {
|
||||
try {
|
||||
const user = (doc.user && typeof doc.user === "object" ? doc.user : await req.payload.findByID({
|
||||
collection: "customers",
|
||||
id: doc.user,
|
||||
req,
|
||||
})) as Customer;
|
||||
const offer = (doc.offer && typeof doc.offer === "object" ? doc.offer : await req.payload.findByID({
|
||||
collection: "offers",
|
||||
id: doc.offer,
|
||||
req,
|
||||
})) as Offer;
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
|
||||
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
|
||||
const accountUrl = `${serverUrl}/konto/termine`;
|
||||
|
||||
if (operation === "create") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
bookingRequestReceivedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
}),
|
||||
);
|
||||
await notifyAdmins(
|
||||
req,
|
||||
newBookingRequestAdminEmail({
|
||||
customerName: user.name,
|
||||
customerEmail: user.email,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
adminUrl,
|
||||
}),
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
|
||||
const statusChanged = previousDoc?.status !== doc.status;
|
||||
|
||||
if (statusChanged && doc.status === "confirmed") {
|
||||
let online: { joinUrl: string; password: string } | undefined;
|
||||
let onsite: { address: string; mapsUrl: string } | undefined;
|
||||
|
||||
if (doc.appointmentType === "online" && doc.linkedEvent) {
|
||||
const event = (typeof doc.linkedEvent === "object" ? doc.linkedEvent : await req.payload.findByID({
|
||||
collection: "events",
|
||||
id: doc.linkedEvent,
|
||||
req,
|
||||
})) as Event;
|
||||
online = {
|
||||
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
|
||||
password: event.meetingPassword || "—",
|
||||
};
|
||||
} else if (doc.appointmentType === "onsite") {
|
||||
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
|
||||
const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean);
|
||||
const address = addressParts.join(", ");
|
||||
onsite = { address, mapsUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` };
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
user.email,
|
||||
bookingConfirmedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: `${fmtTime(doc.startTime)} – ${fmtTime(doc.endTime)}`,
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
online,
|
||||
onsite,
|
||||
}),
|
||||
);
|
||||
} else if (statusChanged && doc.status === "rejected") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
bookingRejectedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
}),
|
||||
);
|
||||
} else if (statusChanged && doc.status === "cancelled") {
|
||||
await notifyAdmins(
|
||||
req,
|
||||
bookingCancelledAdminEmail({
|
||||
customerName: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
adminUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
|
||||
if (proposedNew && doc.status === "pending") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
alternativeProposedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
originalDateLabel: fmtDate(doc.date),
|
||||
originalTimeLabel: fmtTime(doc.startTime),
|
||||
altDateLabel: fmtDate(doc.proposedAlternative.date),
|
||||
altTimeLabel: fmtTime(doc.proposedAlternative.startTime),
|
||||
accountUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Email delivery must never break the booking write itself.
|
||||
req.payload.logger.error({ err, msg: "booking-requests notifyByEmail failed" });
|
||||
}
|
||||
|
||||
return doc;
|
||||
};
|
||||
|
||||
export const BookingRequests: CollectionConfig = {
|
||||
slug: "booking-requests",
|
||||
labels: {
|
||||
singular: "Buchungsanfrage",
|
||||
plural: "Buchungsanfragen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "id",
|
||||
defaultColumns: ["offer", "user", "date", "startTime", "status"],
|
||||
group: "Buchungen",
|
||||
description: "Terminanfragen von Nutzer:innen — bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
|
||||
},
|
||||
access: {
|
||||
// Customer-initiated writes always go through the vetted /api/booking/*
|
||||
// routes (Local API, elevated access, after the route verifies
|
||||
// ownership) — never directly through REST/GraphQL. This keeps a
|
||||
// customer from ever reading/writing another customer's booking, or
|
||||
// setting their own status to "confirmed" directly.
|
||||
create: isAdmin,
|
||||
read: ({ req }) => {
|
||||
if (req.user?.collection === "users") return true;
|
||||
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
|
||||
return false;
|
||||
},
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [handleConfirmation],
|
||||
afterChange: [notifyByEmail],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "user",
|
||||
type: "relationship",
|
||||
relationTo: "customers",
|
||||
label: "Nutzer:in",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "offer",
|
||||
type: "relationship",
|
||||
relationTo: "offers",
|
||||
label: "Angebot",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Datum",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
|
||||
},
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Startzeit",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Endzeit",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "appointmentType",
|
||||
type: "select",
|
||||
label: "Terminart",
|
||||
required: true,
|
||||
defaultValue: "onsite",
|
||||
options: [...APPOINTMENT_TYPES],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
label: "Status",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [...BOOKING_STATUSES],
|
||||
admin: {
|
||||
description: "Auf „Bestätigt“ setzen und speichern, um den Termin verbindlich zu machen (Konfliktprüfung läuft automatisch).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "userMessage",
|
||||
type: "textarea",
|
||||
label: "Nachricht der Nutzerin/des Nutzers",
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "internalNote",
|
||||
type: "textarea",
|
||||
label: "Interne Notiz",
|
||||
access: { read: isAdminFieldLevel, update: isAdminFieldLevel },
|
||||
admin: { description: "Nur für Anna sichtbar." },
|
||||
},
|
||||
{
|
||||
type: "collapsible",
|
||||
label: "Alternativer Termin",
|
||||
fields: [
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "proposedAlternative",
|
||||
type: "group",
|
||||
label: "",
|
||||
fields: [
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Alternatives Datum",
|
||||
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
|
||||
},
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Alternative Startzeit",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Alternative Endzeit",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "linkedEvent",
|
||||
type: "relationship",
|
||||
relationTo: "events",
|
||||
label: "Verknüpfter Video-Termin",
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
|
||||
const user = req.user;
|
||||
if (!user) return false;
|
||||
if (user.collection === "users") return true;
|
||||
if (user.collection === "customers") return { id: { equals: user.id } };
|
||||
return false;
|
||||
};
|
||||
|
||||
export const Customers: CollectionConfig = {
|
||||
slug: "customers",
|
||||
labels: {
|
||||
singular: "Kundin/Kunde",
|
||||
plural: "Kund:innen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "phone"],
|
||||
group: "Buchungen",
|
||||
description: "Konten der Website-Besucher:innen (nicht die Admin-Zugänge unter „Benutzer:innen“).",
|
||||
},
|
||||
auth: {
|
||||
maxLoginAttempts: 8,
|
||||
lockTime: 10 * 60 * 1000,
|
||||
},
|
||||
access: {
|
||||
// Anyone can create their own account (registration); reading/updating
|
||||
// is restricted to the account owner or an admin.
|
||||
create: () => true,
|
||||
read: isSelfOrAdmin,
|
||||
update: isSelfOrAdmin,
|
||||
delete: ({ req }) => req.user?.collection === "users",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "phone",
|
||||
type: "text",
|
||||
label: "Telefonnummer",
|
||||
required: false,
|
||||
admin: {
|
||||
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
|
||||
},
|
||||
},
|
||||
// "email" and "password" are added automatically by the auth config.
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const EventRegistrations: CollectionConfig = {
|
||||
slug: "event-registrations",
|
||||
labels: {
|
||||
singular: "Anmeldung",
|
||||
plural: "Anmeldungen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
|
||||
group: "Inhalte",
|
||||
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
|
||||
},
|
||||
access: {
|
||||
// Visitors register themselves through the public event page.
|
||||
create: () => true,
|
||||
read: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "event",
|
||||
type: "relationship",
|
||||
relationTo: "events",
|
||||
label: "Termin",
|
||||
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: "status",
|
||||
type: "select",
|
||||
label: "Status",
|
||||
required: true,
|
||||
defaultValue: "registered",
|
||||
options: [
|
||||
{ label: "Angemeldet", value: "registered" },
|
||||
{ label: "Storniert", value: "cancelled" },
|
||||
{ label: "Teilgenommen", value: "attended" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "registeredAt",
|
||||
type: "date",
|
||||
label: "Registriert am",
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
admin: { position: "sidebar", date: { pickerAppearance: "dayAndTime" } },
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { position: "sidebar" },
|
||||
fields: [
|
||||
{
|
||||
name: "reminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "reminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
||||
import { slugField } from "@/fields/slug";
|
||||
import { generateMeetingPassword } from "@/lib/meeting/password";
|
||||
|
||||
export const EVENT_CATEGORIES = [
|
||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||
{ label: "Doula", value: "doula" },
|
||||
{ label: "Erdenkinder", value: "erdenkinder" },
|
||||
{ label: "Mädchenkreis", value: "maedchenkreis" },
|
||||
{ label: "Singen im Kreis", value: "singen-im-kreis" },
|
||||
{ label: "Singen für Schwangere", value: "singen-fuer-schwangere" },
|
||||
{ label: "Singen für Mamas mit Baby", value: "singen-fuer-mamas-mit-baby" },
|
||||
{ label: "Sonstiges", value: "sonstiges" },
|
||||
] as const;
|
||||
|
||||
// Auto-generates the meeting password for new online events, and
|
||||
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
||||
// Never derived from the event id/slug — always a fresh random value.
|
||||
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
|
||||
if (!data?.isOnline) return data;
|
||||
if (!data.meetingPassword || data.regeneratePassword) {
|
||||
data.meetingPassword = generateMeetingPassword();
|
||||
}
|
||||
data.regeneratePassword = false;
|
||||
return data;
|
||||
};
|
||||
|
||||
export const Events: CollectionConfig = {
|
||||
slug: "events",
|
||||
labels: {
|
||||
singular: "Termin",
|
||||
plural: "Termine",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "date", "category", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: { interval: 1500 },
|
||||
},
|
||||
},
|
||||
access: {
|
||||
read: publishedOrAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [setMeetingPassword],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
label: "Titel",
|
||||
required: true,
|
||||
},
|
||||
slugField(),
|
||||
{
|
||||
name: "description",
|
||||
type: "richText",
|
||||
label: "Beschreibung",
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Datum",
|
||||
required: true,
|
||||
admin: {
|
||||
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
|
||||
width: "34%",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Startzeit",
|
||||
admin: {
|
||||
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
|
||||
width: "33%",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Endzeit",
|
||||
admin: {
|
||||
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
|
||||
width: "33%",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "location",
|
||||
type: "text",
|
||||
label: "Ort",
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
type: "select",
|
||||
label: "Kategorie",
|
||||
required: true,
|
||||
defaultValue: "sonstiges",
|
||||
options: [...EVENT_CATEGORIES],
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Bild",
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "maxParticipants",
|
||||
type: "number",
|
||||
label: "Maximale Teilnehmerzahl",
|
||||
min: 1,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "registrationRequired",
|
||||
type: "checkbox",
|
||||
label: "Anmeldung erforderlich",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "registrationInfo",
|
||||
type: "text",
|
||||
label: "Anmeldelink bzw. Kontaktmöglichkeit",
|
||||
admin: {
|
||||
description: "z. B. eine E-Mail-Adresse, ein Link oder ein Hinweistext.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "collapsible",
|
||||
label: "Online-Termin (Video-Call)",
|
||||
fields: [
|
||||
{
|
||||
name: "isOnline",
|
||||
type: "checkbox",
|
||||
label: "Online-Termin",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: "Aktiviert Meeting-Passwort, Beitrittsseite und Erinnerungs-E-Mails.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "meetingPassword",
|
||||
type: "text",
|
||||
label: "Meeting-Passwort",
|
||||
admin: {
|
||||
readOnly: true,
|
||||
condition: (data) => Boolean(data?.isOnline),
|
||||
description: "Wird automatisch erzeugt. Ist nicht Teil des Meeting-Links.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "regeneratePassword",
|
||||
type: "checkbox",
|
||||
label: "Neues Passwort generieren (beim Speichern)",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
condition: (data) => Boolean(data?.isOnline),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { condition: (data) => Boolean(data?.isOnline) },
|
||||
fields: [
|
||||
{
|
||||
name: "reminder60Enabled",
|
||||
type: "checkbox",
|
||||
label: "Erinnerung 60 Minuten vorher",
|
||||
defaultValue: true,
|
||||
admin: { width: "34%" },
|
||||
},
|
||||
{
|
||||
name: "reminder30Enabled",
|
||||
type: "checkbox",
|
||||
label: "Erinnerung 30 Minuten vorher",
|
||||
defaultValue: true,
|
||||
admin: { width: "33%" },
|
||||
},
|
||||
{
|
||||
name: "hostReminderEnabled",
|
||||
type: "checkbox",
|
||||
label: "Auch Host per E-Mail erinnern",
|
||||
defaultValue: true,
|
||||
admin: { width: "33%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { condition: (data) => Boolean(data?.isOnline) },
|
||||
fields: [
|
||||
{
|
||||
name: "hostReminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "hostReminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "isPrivateBooking",
|
||||
type: "checkbox",
|
||||
label: "Aus privater Buchungsanfrage erzeugt",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
readOnly: true,
|
||||
description: "Wird automatisch gesetzt, wenn dieser Termin aus einer bestätigten Einzelbuchung entstanden ist. Solche Termine bleiben unveröffentlicht.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bookingRequest",
|
||||
type: "relationship",
|
||||
relationTo: "booking-requests",
|
||||
label: "Zugehörige Buchungsanfrage",
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
labels: {
|
||||
singular: "Bild",
|
||||
plural: "Bilder",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "alt",
|
||||
description: "Bilder für die Website — Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
|
||||
},
|
||||
access: {
|
||||
// Images are public so the website can display them without a login.
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, "..", "media"),
|
||||
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
|
||||
imageSizes: [
|
||||
{ name: "thumbnail", width: 400, position: "centre" },
|
||||
{ name: "card", width: 800, position: "centre" },
|
||||
{ name: "hero", width: 1600, position: "centre" },
|
||||
],
|
||||
adminThumbnail: "thumbnail",
|
||||
formatOptions: { format: "webp", options: { quality: 82 } },
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "alt",
|
||||
type: "text",
|
||||
label: "Alt-Text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Kurze Beschreibung des Bildes für Screenreader und Suchmaschinen.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caption",
|
||||
type: "textarea",
|
||||
label: "Bildbeschreibung",
|
||||
admin: {
|
||||
description: "Optionale, längere Beschreibung (z. B. für eine Bildunterschrift).",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
||||
import { slugField } from "@/fields/slug";
|
||||
|
||||
// Groups the 7 individual offers for the mega menu and the /angebote
|
||||
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
|
||||
// bundles the three singing circles — matching the site's navigation).
|
||||
export const OFFER_CATEGORIES = [
|
||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||
{ label: "Doula-Begleitung", value: "doula-begleitung" },
|
||||
{ label: "Kindergruppen", value: "kindergruppen" },
|
||||
{ label: "Singkreise", value: "singkreise" },
|
||||
] as const;
|
||||
|
||||
export const Offers: CollectionConfig = {
|
||||
slug: "offers",
|
||||
labels: {
|
||||
singular: "Angebot",
|
||||
plural: "Angebote",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "category", "order", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Die Angebote von Anouma — erscheinen im Menü und auf /angebote.",
|
||||
},
|
||||
defaultSort: "order",
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
access: {
|
||||
read: publishedOrAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
label: "Titel",
|
||||
required: true,
|
||||
},
|
||||
slugField(),
|
||||
{
|
||||
name: "shortDescription",
|
||||
type: "textarea",
|
||||
label: "Kurzbeschreibung",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Kurzer Satz für Karten, Menü und Übersicht.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "richText",
|
||||
label: "Beschreibung",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Bild",
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
type: "select",
|
||||
label: "Kategorie",
|
||||
required: true,
|
||||
options: [...OFFER_CATEGORIES],
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
type: "number",
|
||||
label: "Reihenfolge",
|
||||
defaultValue: 0,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Kleinere Zahl erscheint zuerst.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "visibility",
|
||||
type: "select",
|
||||
label: "Sichtbarkeit",
|
||||
required: true,
|
||||
defaultValue: "public",
|
||||
options: [
|
||||
{ label: "Öffentlich (Menü, Übersicht)", value: "public" },
|
||||
{ label: "Privat (nur über direkten Link/Buchung)", value: "private" },
|
||||
],
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Private Angebote wie „Einzelbegleitung“ erscheinen nicht im Menü oder auf /angebote.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bookable",
|
||||
type: "checkbox",
|
||||
label: "Buchbar",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Aktiviert die Terminbuchung mit Kalenderauswahl auf der Angebotsseite.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "price",
|
||||
type: "text",
|
||||
label: "Preis bzw. Preistext",
|
||||
admin: { width: "50%", description: "z. B. „80 €“ oder „auf Anfrage“." },
|
||||
},
|
||||
{
|
||||
name: "durationMinutes",
|
||||
type: "number",
|
||||
label: "Dauer (Minuten)",
|
||||
min: 5,
|
||||
admin: { width: "50%", condition: (data) => Boolean(data?.bookable) },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
||||
import { slugField } from "@/fields/slug";
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
labels: {
|
||||
singular: "Beitrag",
|
||||
plural: "Beiträge",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "publishDate", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Neuigkeiten und Beiträge für die Seite „Aktuelles“.",
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: { interval: 1500 },
|
||||
},
|
||||
},
|
||||
access: {
|
||||
read: publishedOrAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
label: "Titel",
|
||||
required: true,
|
||||
},
|
||||
slugField(),
|
||||
{
|
||||
name: "teaser",
|
||||
type: "textarea",
|
||||
label: "Teaser",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Kurzer Anrisstext für die Übersicht.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
label: "Inhalt",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "coverImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Titelbild",
|
||||
},
|
||||
{
|
||||
name: "publishDate",
|
||||
type: "date",
|
||||
label: "Veröffentlichungsdatum",
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
labels: {
|
||||
singular: "Benutzer:in",
|
||||
plural: "Benutzer:innen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "role"],
|
||||
description: "Zugänge für den geschützten Admin-Bereich.",
|
||||
},
|
||||
auth: {
|
||||
// Payload shows a "create first user" screen automatically when this
|
||||
// collection is empty, bypassing normal access control just for that
|
||||
// one-time setup — no hardcoded default password is ever needed.
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 10 * 60 * 1000,
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "select",
|
||||
label: "Rolle",
|
||||
required: true,
|
||||
defaultValue: "admin",
|
||||
// Only "admin" exists today; add further roles here later and extend
|
||||
// the checks in access/index.ts to match.
|
||||
options: [{ label: "Administrator:in", value: "admin" }],
|
||||
access: {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
},
|
||||
// "email" and "password" are added automatically by `auth: true`-style config.
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user