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:
maro
2026-08-25 16:40:51 +02:00
commit 45261a0461
138 changed files with 23263 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { OFFER_CATEGORIES } from "@/collections/Offers";
import type { Offer } from "@/payload-types";
export type ImageMood =
| "plum"
| "dustyrose"
| "moss"
| "caramel"
| "mauve"
| "sand"
| "rose"
| "peach";
// Purely a presentation fallback for offers/events without an uploaded photo
// yet — content itself always comes from the CMS.
const moodBySlug: Record<string, ImageMood> = {
prozessbegleitung: "plum",
"doula-begleitung": "dustyrose",
erdenkinder: "moss",
maedchenkreis: "caramel",
"singen-im-kreis": "sand",
"singen-fuer-schwangere": "peach",
"mama-baby-singkreis": "rose",
};
const moodByCategory: Record<string, ImageMood> = {
prozessbegleitung: "plum",
"doula-begleitung": "dustyrose",
kindergruppen: "moss",
singkreise: "rose",
};
export function moodForOffer(offer: Pick<Offer, "slug" | "category">): ImageMood {
return (offer.slug ? moodBySlug[offer.slug] : undefined) ?? moodByCategory[offer.category] ?? "mauve";
}
export type OfferCategoryGroup = {
category: (typeof OFFER_CATEGORIES)[number]["value"];
label: string;
href: string;
offers: Offer[];
};
const categoryHref: Record<string, string> = {
prozessbegleitung: "/angebote/prozessbegleitung",
"doula-begleitung": "/angebote/doula-begleitung",
kindergruppen: "/angebote#kindergruppen",
singkreise: "/angebote/singkreise",
};
/**
* Groups the flat `offers` collection into the four nav/overview sections.
* Private offers (e.g. "Einzelbegleitung") are deliberately excluded — they
* stay reachable only via their own direct booking link.
*/
export function groupOffersByCategory(offers: Offer[]): OfferCategoryGroup[] {
const publicOffers = offers.filter((offer) => offer.visibility !== "private");
return OFFER_CATEGORIES.map(({ value, label }) => ({
category: value,
label,
href: categoryHref[value] ?? "/angebote",
offers: publicOffers
.filter((offer) => offer.category === value)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
})).filter((group) => group.offers.length > 0);
}
+12
View File
@@ -0,0 +1,12 @@
import { headers } from "next/headers";
import { getPayload } from "payload";
import config from "@payload-config";
import type { Customer } from "@/payload-types";
/** Returns the logged-in customer for the current request, or null. Never returns an admin user. */
export async function getCurrentCustomer(): Promise<Customer | null> {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: await headers() });
if (!user || user.collection !== "customers") return null;
return user as Customer;
}
+25
View File
@@ -0,0 +1,25 @@
import { sql } from "drizzle-orm";
import type { PayloadRequest } from "payload";
/**
* Acquires a Postgres transaction-scoped advisory lock so that two
* concurrent requests confirming a booking for the same day can't both pass
* the "is this slot free?" check before either has committed (Payload's
* default transaction isolation is READ COMMITTED, which alone would allow
* that race). The lock is automatically released when the transaction ends.
*
* Must be called from inside a Payload hook/operation that already has an
* active `req.transactionID` (true for collection create/update by default).
*/
export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise<void> {
const txID = req.transactionID ? String(await req.transactionID) : undefined;
if (!txID) return; // No active transaction — nothing to lock against.
const adapter = req.payload.db as unknown as {
sessions?: Record<string, { db: { execute: (query: unknown) => Promise<unknown> } }>;
drizzle: { execute: (query: unknown) => Promise<unknown> };
};
const tx = adapter.sessions?.[txID]?.db ?? adapter.drizzle;
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${dateKey}))`);
}
+31
View File
@@ -0,0 +1,31 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
import type { BookingRequest } from "@/payload-types";
/**
* These are called only after the caller has already verified who the
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
* filter below is what actually scopes results to that one customer.
*/
export const getCustomerBookings = cache(async (customerId: number): Promise<BookingRequest[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "booking-requests",
where: { user: { equals: customerId } },
sort: "-date",
depth: 2,
limit: 200,
});
return result.docs;
});
export async function getCustomerBookingById(customerId: number, id: number): Promise<BookingRequest | null> {
const payload = await getCMS();
const result = await payload.find({
collection: "booking-requests",
where: { and: [{ id: { equals: id } }, { user: { equals: customerId } }] },
depth: 2,
limit: 1,
});
return result.docs[0] ?? null;
}
+116
View File
@@ -0,0 +1,116 @@
import type { Payload } from "payload";
import { WEEKDAYS } from "@/collections/Availability";
export type TimeSlot = { start: Date; end: Date };
const WEEKDAY_BY_INDEX = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;
function dateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function timeOfDayMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
}
function atMinutesOfDay(day: Date, minutes: number): Date {
const d = new Date(day);
d.setHours(0, Math.round(minutes), 0, 0);
return d;
}
function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
return aStart < bEnd && bStart < aEnd;
}
/**
* Computes bookable slots for one offer across a date range, from the
* configured weekly Availability (plus per-date AvailabilityOverrides),
* minus anything already pending/confirmed on Anna's schedule — she can
* only do one thing at a time, regardless of which offer it's for.
*/
export async function getAvailableSlots(
payload: Payload,
args: { durationMinutes: number; from: Date; to: Date },
): Promise<Map<string, TimeSlot[]>> {
const { durationMinutes, from, to } = args;
const result = new Map<string, TimeSlot[]>();
const [{ docs: weekly }, { docs: overrides }, { docs: busyBookings }] = await Promise.all([
payload.find({ collection: "availability", where: { active: { equals: true } }, limit: 100 }),
payload.find({
collection: "availability-overrides",
where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] },
limit: 200,
}),
payload.find({
collection: "booking-requests",
where: {
and: [
{ status: { in: ["pending", "confirmed"] } },
{ date: { greater_than_equal: from.toISOString() } },
{ date: { less_than_equal: to.toISOString() } },
],
},
limit: 500,
}),
]);
const overridesByDate = new Map(overrides.map((o) => [dateKey(new Date(o.date)), o]));
const now = new Date();
for (let day = new Date(from); day <= to; day = new Date(day.getTime() + 24 * 60 * 60 * 1000)) {
const key = dateKey(day);
const override = overridesByDate.get(key);
let windows: { startMinutes: number; endMinutes: number }[] = [];
if (override?.type === "unavailable") {
windows = [];
} else if (override?.type === "custom-hours" && override.startTime && override.endTime) {
windows = [
{
startMinutes: timeOfDayMinutes(new Date(override.startTime)),
endMinutes: timeOfDayMinutes(new Date(override.endTime)),
},
];
} else {
const weekdayName = WEEKDAY_BY_INDEX[day.getDay()];
windows = weekly
.filter((w) => w.weekday === weekdayName)
.map((w) => ({
startMinutes: timeOfDayMinutes(new Date(w.startTime)),
endMinutes: timeOfDayMinutes(new Date(w.endTime)),
}));
}
const dayBusy = busyBookings
.filter((b) => dateKey(new Date(b.date)) === key)
.map((b) => ({ start: new Date(b.startTime), end: new Date(b.endTime) }));
const slots: TimeSlot[] = [];
for (const window of windows) {
for (let start = window.startMinutes; start + durationMinutes <= window.endMinutes; start += durationMinutes) {
const slotStart = atMinutesOfDay(day, start);
const slotEnd = atMinutesOfDay(day, start + durationMinutes);
if (slotStart < now) continue;
if (dayBusy.some((b) => overlaps(slotStart, slotEnd, b.start, b.end))) continue;
slots.push({ start: slotStart, end: slotEnd });
}
}
if (slots.length > 0) result.set(key, slots);
}
return result;
}
export { WEEKDAYS };
+152
View File
@@ -0,0 +1,152 @@
import { escapeHtml, paragraph, renderButton, renderEmailShell, renderFactBox } from "./layout";
type BaseArgs = {
name: string;
offerTitle: string;
dateLabel: string;
timeLabel: string;
};
export function bookingRequestReceivedEmail(args: BaseArgs & { appointmentTypeLabel: string }): { subject: string; html: string } {
const html = renderEmailShell({
bodyHtml: [
paragraph(`Hallo ${args.name},`),
paragraph("deine Terminanfrage bei ANOUMA wurde übermittelt.", { italic: true }),
renderFactBox([
{ label: "Angebot", value: args.offerTitle },
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
{ label: "Terminart", value: args.appointmentTypeLabel },
]),
paragraph("Der Termin ist noch nicht verbindlich bestätigt. Du erhältst eine weitere E-Mail, sobald Anna deine Anfrage bestätigt hat.", { small: true }),
].join("\n"),
});
return { subject: "Deine Terminanfrage bei ANOUMA", html };
}
export function newBookingRequestAdminEmail(args: {
customerName: string;
customerEmail: string;
offerTitle: string;
dateLabel: string;
timeLabel: string;
appointmentTypeLabel: string;
adminUrl: string;
}): { subject: string; html: string } {
const html = renderEmailShell({
bodyHtml: [
paragraph("Neue Terminanfrage", { italic: true }),
renderFactBox([
{ label: "Name", value: args.customerName },
{ label: "E-Mail", value: args.customerEmail },
{ label: "Angebot", value: args.offerTitle },
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
{ label: "Terminart", value: args.appointmentTypeLabel },
]),
renderButton("Im Admin-Bereich ansehen", args.adminUrl),
].join("\n"),
});
return { subject: "Neue Terminanfrage", html };
}
export function bookingConfirmedEmail(
args: BaseArgs & {
appointmentTypeLabel: string;
online?: { joinUrl: string; password: string };
onsite?: { address: string; mapsUrl: string };
},
): { subject: string; html: string } {
const rows = [
{ label: "Angebot", value: args.offerTitle },
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
{ label: "Ort", value: args.appointmentTypeLabel },
];
if (args.onsite) rows.push({ label: "Adresse", value: args.onsite.address });
const extras: string[] = [];
if (args.online) {
extras.push(renderButton("Meeting betreten", args.online.joinUrl));
extras.push(
`<p style="margin:0 0 4px;color:#66505f;font-size:14px;"><strong>Meeting-Passwort:</strong></p>` +
`<p style="margin:0 0 20px;color:#66505f;font-size:20px;letter-spacing:0.08em;font-family:'Courier New',monospace;">${escapeHtml(args.online.password)}</p>`,
);
}
if (args.onsite) {
extras.push(renderButton("Route öffnen", args.onsite.mapsUrl));
}
const html = renderEmailShell({
bodyHtml: [
paragraph(`Hallo ${args.name},`),
paragraph("dein Termin bei ANOUMA wurde bestätigt.", { italic: true }),
renderFactBox(rows),
...extras,
].join("\n"),
});
return { subject: "Dein Termin bei ANOUMA wurde bestätigt", html };
}
export function bookingRejectedEmail(args: BaseArgs): { subject: string; html: string } {
const html = renderEmailShell({
bodyHtml: [
paragraph(`Hallo ${args.name},`),
paragraph("leider kann der folgende Termin nicht stattfinden.", { italic: true }),
renderFactBox([
{ label: "Angebot", value: args.offerTitle },
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
]),
paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem ANOUMA-Konto vorbei.", { small: true }),
].join("\n"),
});
return { subject: "Deine Terminanfrage bei ANOUMA", html };
}
export function alternativeProposedEmail(args: {
name: string;
offerTitle: string;
originalDateLabel: string;
originalTimeLabel: string;
altDateLabel: string;
altTimeLabel: string;
accountUrl: string;
}): { subject: string; html: string } {
const html = renderEmailShell({
bodyHtml: [
paragraph(`Hallo ${args.name},`),
paragraph("Anna hat einen alternativen Termin für dich vorgeschlagen.", { italic: true }),
renderFactBox([
{ label: "Angebot", value: args.offerTitle },
{ label: "Ursprünglich angefragt", value: `${args.originalDateLabel}, ${args.originalTimeLabel}` },
{ label: "Neuer Vorschlag", value: `${args.altDateLabel}, ${args.altTimeLabel}` },
]),
renderButton("Zum Termin in meinem Konto", args.accountUrl),
paragraph("Dort kannst du den neuen Termin annehmen oder einen anderen anfragen.", { small: true }),
].join("\n"),
});
return { subject: "Alternativer Terminvorschlag von ANOUMA", html };
}
export function bookingCancelledAdminEmail(args: {
customerName: string;
offerTitle: string;
dateLabel: string;
timeLabel: string;
adminUrl: string;
}): { subject: string; html: string } {
const html = renderEmailShell({
bodyHtml: [
paragraph("Ein Termin wurde storniert.", { italic: true }),
renderFactBox([
{ label: "Name", value: args.customerName },
{ label: "Angebot", value: args.offerTitle },
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
]),
renderButton("Im Admin-Bereich ansehen", args.adminUrl),
].join("\n"),
});
return { subject: "Termin storniert", html };
}
+80
View File
@@ -0,0 +1,80 @@
const colors = {
mauveDark: "#8b616d",
plum: "#66505f",
cream: "#f2e2d6",
creamLight: "#fbf6f1",
};
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function renderButton(label: string, href: string): string {
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px auto;">
<tr>
<td style="border-radius:999px;background:${colors.mauveDark};">
<a href="${href}" style="display:inline-block;padding:14px 32px;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.02em;">
${escapeHtml(label)}
</a>
</td>
</tr>
</table>`;
}
export function renderFactBox(rows: { label: string; value: string }[]): string {
const cells = rows
.map(
(row, i) => `<p style="margin:0 ${i === rows.length - 1 ? "0" : "0 12px"} 0;color:${colors.plum};font-size:14px;">
<strong>${escapeHtml(row.label)}:</strong><br />${escapeHtml(row.value)}
</p>`,
)
.join("\n");
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.cream};border-radius:16px;">
<tr><td style="padding:20px 24px;">${cells}</td></tr>
</table>`;
}
/** Shared table-based, inline-styled email shell — same design language across all ANOUMA emails. */
export function renderEmailShell(args: { preheader?: string; bodyHtml: string; footerText?: string }): string {
const { bodyHtml, footerText = "Räume für Verbindung — mit dir selbst, miteinander und mit der Natur." } = args;
return `<!doctype html>
<html lang="de">
<body style="margin:0;padding:0;background:${colors.creamLight};font-family:Georgia,'Times New Roman',serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.creamLight};padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:24px;overflow:hidden;">
<tr>
<td style="background:${colors.mauveDark};padding:28px 32px;text-align:center;">
<span style="color:#ffffff;font-size:22px;letter-spacing:0.12em;font-weight:600;">ANOUMA</span>
</td>
</tr>
<tr>
<td style="padding:32px;">
${bodyHtml}
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:${colors.cream};text-align:center;">
<span style="color:${colors.plum};font-size:12px;">${escapeHtml(footerText)}</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export function paragraph(text: string, opts: { italic?: boolean; small?: boolean } = {}): string {
const size = opts.small ? "13px" : "16px";
const style = opts.italic ? "font-style:italic;" : "";
return `<p style="margin:0 0 20px;color:${colors.plum};font-size:${size};line-height:1.6;${style}">${escapeHtml(text)}</p>`;
}
+114
View File
@@ -0,0 +1,114 @@
export type ReminderEmailData = {
recipientName: string;
eventTitle: string;
dateLabel: string;
timeLabel: string;
joinUrl: string;
meetingPassword: string;
minutesBefore: number;
};
const colors = {
mauveDark: "#8b616d",
plum: "#66505f",
cream: "#f2e2d6",
creamLight: "#fbf6f1",
};
/** Table-based HTML email (inline styles only) matching the ANOUMA design. */
export function renderReminderEmailHtml(data: ReminderEmailData): string {
const { recipientName, eventTitle, dateLabel, timeLabel, joinUrl, meetingPassword, minutesBefore } = data;
return `<!doctype html>
<html lang="de">
<body style="margin:0;padding:0;background:${colors.creamLight};font-family:Georgia,'Times New Roman',serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.creamLight};padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:24px;overflow:hidden;">
<tr>
<td style="background:${colors.mauveDark};padding:28px 32px;text-align:center;">
<span style="color:#ffffff;font-size:22px;letter-spacing:0.12em;font-weight:600;">ANOUMA</span>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 20px;color:${colors.plum};font-size:16px;line-height:1.6;">
Hallo ${escapeHtml(recipientName)},
</p>
<p style="margin:0 0 24px;color:${colors.plum};font-size:18px;line-height:1.6;font-style:italic;">
Dein Online-Termin bei ANOUMA beginnt in ${minutesBefore} Minuten.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.cream};border-radius:16px;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 12px;color:${colors.plum};font-size:14px;">
<strong>Termin:</strong><br />${escapeHtml(eventTitle)}
</p>
<p style="margin:0 0 12px;color:${colors.plum};font-size:14px;">
<strong>Datum:</strong><br />${escapeHtml(dateLabel)}
</p>
<p style="margin:0;color:${colors.plum};font-size:14px;">
<strong>Uhrzeit:</strong><br />${escapeHtml(timeLabel)} Uhr
</p>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px auto;">
<tr>
<td style="border-radius:999px;background:${colors.mauveDark};">
<a href="${joinUrl}" style="display:inline-block;padding:14px 32px;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.02em;">
Meeting betreten
</a>
</td>
</tr>
</table>
<p style="margin:0 0 4px;color:${colors.plum};font-size:14px;">
<strong>Meeting-Passwort:</strong>
</p>
<p style="margin:0 0 20px;color:${colors.plum};font-size:20px;letter-spacing:0.08em;font-family:'Courier New',monospace;">
${escapeHtml(meetingPassword)}
</p>
<p style="margin:0;color:${colors.plum};font-size:13px;line-height:1.6;">
Bitte halte dein Passwort bereit. Beim Betreten des Meetings wirst du außerdem nach deinem Namen gefragt.
</p>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:${colors.cream};text-align:center;">
<span style="color:${colors.plum};font-size:12px;">Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export function renderReminderEmailText(data: ReminderEmailData): string {
const { recipientName, eventTitle, dateLabel, timeLabel, joinUrl, meetingPassword, minutesBefore } = data;
return [
`Hallo ${recipientName},`,
"",
`Dein Online-Termin bei ANOUMA beginnt in ${minutesBefore} Minuten.`,
"",
`Termin: ${eventTitle}`,
`Datum: ${dateLabel}`,
`Uhrzeit: ${timeLabel} Uhr`,
"",
`Meeting betreten: ${joinUrl}`,
`Meeting-Passwort: ${meetingPassword}`,
"",
"Bitte halte dein Passwort bereit. Beim Betreten des Meetings wirst du außerdem nach deinem Namen gefragt.",
].join("\n");
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+6
View File
@@ -0,0 +1,6 @@
import { getMailFrom, getMailTransport } from "./transport";
export async function sendEmail(to: string, template: { subject: string; html: string }) {
const transport = getMailTransport();
await transport.sendMail({ from: getMailFrom(), to, subject: template.subject, html: template.html });
}
+13
View File
@@ -0,0 +1,13 @@
import { getMailFrom, getMailTransport } from "./transport";
import { renderReminderEmailHtml, renderReminderEmailText, type ReminderEmailData } from "./reminderTemplate";
export async function sendReminderEmail(to: string, data: ReminderEmailData) {
const transport = getMailTransport();
await transport.sendMail({
from: getMailFrom(),
to,
subject: `Dein Online-Termin bei ANOUMA beginnt in ${data.minutesBefore} Minuten`,
html: renderReminderEmailHtml(data),
text: renderReminderEmailText(data),
});
}
+25
View File
@@ -0,0 +1,25 @@
import nodemailer from "nodemailer";
let cached: nodemailer.Transporter | null = null;
/** SMTP transporter for the existing ANOUMA mail system — no external newsletter service. */
export function getMailTransport(): nodemailer.Transporter {
if (cached) return cached;
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD } = process.env;
if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASSWORD) {
throw new Error("SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASSWORD must be set to send email.");
}
cached = nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT),
secure: Number(SMTP_PORT) === 465,
auth: { user: SMTP_USER, pass: SMTP_PASSWORD },
});
return cached;
}
export function getMailFrom(): string {
return process.env.SMTP_FROM || "ANOUMA <support@anouma.org>";
}
+27
View File
@@ -0,0 +1,27 @@
/** Day + short month, e.g. { day: "15", month: "SEP" } — for the date badge on event cards. */
export function formatEventDateParts(iso: string) {
const date = new Date(iso);
return {
day: date.toLocaleDateString("de-DE", { day: "2-digit" }),
month: date.toLocaleDateString("de-DE", { month: "short" }).toUpperCase().replace(".", ""),
};
}
export function formatFullDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
}
export function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
export function formatTimeRange(start?: string | null, end?: string | null) {
if (start && end) return `${formatTime(start)} ${formatTime(end)} Uhr`;
if (start) return `${formatTime(start)} Uhr`;
return null;
}
+13
View File
@@ -0,0 +1,13 @@
import { randomInt } from "node:crypto";
// Unambiguous alphabet (no 0/O/1/I/l) — easier to read aloud/type correctly.
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
/**
* Cryptographically random meeting password, e.g. "K7mQ-42Px". Independent
* of the event's id/slug — never derive it from the meeting identifier.
*/
export function generateMeetingPassword(): string {
const chars = Array.from({ length: 8 }, () => ALPHABET[randomInt(ALPHABET.length)]);
return `${chars.slice(0, 4).join("")}-${chars.slice(4).join("")}`;
}
+22
View File
@@ -0,0 +1,22 @@
import type { MeetingRole } from "./token";
export type ParticipantSummary = {
participantId: string;
name: string;
role: MeetingRole;
};
export type ClientToServerMessage =
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
| { type: "kick"; targetParticipantId: string };
export type ServerToClientMessage =
| { type: "welcome"; participantId: string; role: MeetingRole; participants: ParticipantSummary[] }
| ({ type: "peer-joined" } & ParticipantSummary)
| { type: "peer-left"; participantId: string }
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
| { type: "kicked" };
+118
View File
@@ -0,0 +1,118 @@
import { getPayload } from "payload";
import config from "@payload-config";
import { combineDateAndTime } from "./status";
import { sendReminderEmail } from "@/lib/email/sendReminderEmail";
const REMINDER_TOLERANCE_MS = 10 * 60 * 1000; // cron may run every few minutes
function isDue(reminderAt: number, now: number): boolean {
return now >= reminderAt && now < reminderAt + REMINDER_TOLERANCE_MS;
}
function formatDate(date: Date): string {
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
/**
* Checks all upcoming online events for due 60/30-minute reminders and sends
* them — to registered, non-cancelled participants and (optionally) to the
* admin host. Idempotent: every send is guarded by a "...Sent" flag so a
* cron job that runs every few minutes never double-sends.
*/
export async function runEventReminders() {
const payload = await getPayload({ config });
const now = new Date();
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
const windowStart = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
const windowEnd = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString();
const { docs: events } = await payload.find({
collection: "events",
where: {
and: [
{ isOnline: { equals: true } },
{ date: { greater_than_equal: windowStart } },
{ date: { less_than_equal: windowEnd } },
],
},
limit: 200,
});
const results = { emailsSent: 0, eventsChecked: events.length };
for (const event of events) {
if (!event.slug) continue;
const start = combineDateAndTime(event.date, event.startTime);
const dateLabel = formatDate(start);
const timeLabel = formatTime(start);
const joinUrl = `${serverUrl}/termine/${event.slug}/beitreten`;
const thresholds: { minutes: 60 | 30; enabled: boolean; participantField: "reminder60Sent" | "reminder30Sent"; hostField: "hostReminder60Sent" | "hostReminder30Sent" }[] = [
{ minutes: 60, enabled: Boolean(event.reminder60Enabled), participantField: "reminder60Sent", hostField: "hostReminder60Sent" },
{ minutes: 30, enabled: Boolean(event.reminder30Enabled), participantField: "reminder30Sent", hostField: "hostReminder30Sent" },
];
for (const threshold of thresholds) {
if (!threshold.enabled) continue;
const reminderAt = start.getTime() - threshold.minutes * 60_000;
if (!isDue(reminderAt, now.getTime())) continue;
// Host reminder (per event, not per registration).
if (event.hostReminderEnabled && !event[threshold.hostField]) {
const { docs: admins } = await payload.find({ collection: "users", limit: 50 });
for (const admin of admins) {
if (!admin.email) continue;
await sendReminderEmail(admin.email, {
recipientName: admin.name || "Team ANOUMA",
eventTitle: event.title,
dateLabel,
timeLabel,
joinUrl,
meetingPassword: event.meetingPassword || "—",
minutesBefore: threshold.minutes,
});
results.emailsSent++;
}
await payload.update({ collection: "events", id: event.id, data: { [threshold.hostField]: true } });
}
// Participant reminders — only registered (not cancelled), not yet sent.
const { docs: registrations } = await payload.find({
collection: "event-registrations",
where: {
and: [
{ event: { equals: event.id } },
{ status: { equals: "registered" } },
{ [threshold.participantField]: { equals: false } },
],
},
limit: 500,
});
for (const registration of registrations) {
await sendReminderEmail(registration.email, {
recipientName: registration.name,
eventTitle: event.title,
dateLabel,
timeLabel,
joinUrl,
meetingPassword: event.meetingPassword || "—",
minutesBefore: threshold.minutes,
});
results.emailsSent++;
await payload.update({
collection: "event-registrations",
id: registration.id,
data: { [threshold.participantField]: true },
});
}
}
}
return results;
}
+76
View File
@@ -0,0 +1,76 @@
import type { WebSocket } from "ws";
import type { MeetingRole } from "./token";
export type Participant = {
ws: WebSocket;
participantId: string;
name: string;
role: MeetingRole;
};
type Room = {
participants: Map<string, Participant>;
kickedParticipantIds: Set<string>;
};
// In-memory only — this server process is the single source of truth for
// signaling (no media is ever stored or relayed through it). If this app is
// ever scaled to multiple instances, this state needs to move to a shared
// store (e.g. Redis pub/sub) alongside a real SFU.
const rooms = new Map<string, Room>();
function getOrCreateRoom(eventSlug: string): Room {
let room = rooms.get(eventSlug);
if (!room) {
room = { participants: new Map(), kickedParticipantIds: new Set() };
rooms.set(eventSlug, room);
}
return room;
}
export function isKicked(eventSlug: string, participantId: string): boolean {
return rooms.get(eventSlug)?.kickedParticipantIds.has(participantId) ?? false;
}
export function joinRoom(eventSlug: string, participant: Participant): Participant[] {
const room = getOrCreateRoom(eventSlug);
const existing = [...room.participants.values()];
room.participants.set(participant.participantId, participant);
return existing;
}
export function leaveRoom(eventSlug: string, participantId: string) {
const room = rooms.get(eventSlug);
if (!room) return;
room.participants.delete(participantId);
if (room.participants.size === 0) rooms.delete(eventSlug);
}
export function kickFromRoom(eventSlug: string, participantId: string) {
const room = rooms.get(eventSlug);
if (!room) return;
room.kickedParticipantIds.add(participantId);
room.participants.delete(participantId);
}
export function getParticipant(eventSlug: string, participantId: string): Participant | undefined {
return rooms.get(eventSlug)?.participants.get(participantId);
}
export function getRoomParticipants(eventSlug: string): Participant[] {
return [...(rooms.get(eventSlug)?.participants.values() ?? [])];
}
export function broadcast(
eventSlug: string,
message: unknown,
opts: { exclude?: string } = {},
) {
const room = rooms.get(eventSlug);
if (!room) return;
const data = JSON.stringify(message);
for (const p of room.participants.values()) {
if (opts.exclude && p.participantId === opts.exclude) continue;
if (p.ws.readyState === p.ws.OPEN) p.ws.send(data);
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { IncomingMessage } from "node:http";
import type { WebSocket, WebSocketServer } from "ws";
import { verifyMeetingToken } from "./token";
import {
broadcast,
getParticipant,
isKicked,
joinRoom,
kickFromRoom,
leaveRoom,
type Participant,
} from "./rooms";
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
function participantSummary(p: Participant): ParticipantSummary {
return { participantId: p.participantId, name: p.name, role: p.role };
}
export function attachSignalingServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
const url = new URL(req.url ?? "", "http://internal");
const token = url.searchParams.get("token") ?? "";
const payload = verifyMeetingToken(token);
if (!payload) {
ws.close(4000, "invalid-token");
return;
}
if (isKicked(payload.eventSlug, payload.participantId)) {
ws.close(4001, "removed-by-host");
return;
}
const self: Participant = {
ws,
participantId: payload.participantId,
name: payload.name,
role: payload.role,
};
const existing = joinRoom(payload.eventSlug, self);
ws.send(
JSON.stringify({
type: "welcome",
participantId: self.participantId,
role: self.role,
participants: existing.map(participantSummary),
}),
);
broadcast(payload.eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
exclude: self.participantId,
});
ws.on("message", (raw) => {
let message: ClientToServerMessage;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") {
const target = getParticipant(payload.eventSlug, message.to);
if (target && target.ws.readyState === target.ws.OPEN) {
target.ws.send(
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
);
}
return;
}
if (message.type === "kick") {
if (self.role !== "host") return; // server-side authority, not client-claimed
const target = getParticipant(payload.eventSlug, message.targetParticipantId);
if (!target) return;
kickFromRoom(payload.eventSlug, target.participantId);
target.ws.send(JSON.stringify({ type: "kicked" }));
target.ws.close(4001, "removed-by-host");
broadcast(payload.eventSlug, { type: "peer-left", participantId: target.participantId });
}
});
ws.on("close", () => {
leaveRoom(payload.eventSlug, self.participantId);
broadcast(payload.eventSlug, { type: "peer-left", participantId: self.participantId });
});
});
}
+51
View File
@@ -0,0 +1,51 @@
export type MeetingStatus = "scheduled" | "open" | "live" | "ended";
/**
* Payload's "time only" date fields still store a full ISO timestamp, just
* with an arbitrary date portion — this combines the real event date with
* the hour/minute from a time-only field into one correct Date.
*/
export function combineDateAndTime(dateISO: string, timeISO?: string | null): Date {
const date = new Date(dateISO);
if (!timeISO) return date;
const time = new Date(timeISO);
const combined = new Date(date);
combined.setHours(time.getHours(), time.getMinutes(), 0, 0);
return combined;
}
export type MeetingWindow = {
start: Date;
end: Date;
joinWindowMinutes: number;
closeAfterMinutes: number;
now?: Date;
};
export function getMeetingStatus({
start,
end,
joinWindowMinutes,
closeAfterMinutes,
now = new Date(),
}: MeetingWindow): MeetingStatus {
const openAt = start.getTime() - joinWindowMinutes * 60_000;
const closeAt = end.getTime() + closeAfterMinutes * 60_000;
const t = now.getTime();
if (t >= closeAt) return "ended";
if (t >= start.getTime()) return "live";
if (t >= openAt) return "open";
return "scheduled";
}
export function canJoinMeeting(status: MeetingStatus): boolean {
return status === "open" || status === "live";
}
export const statusLabel: Record<MeetingStatus, string> = {
scheduled: "Online-Termin",
open: "Termin beginnt bald",
live: "Jetzt teilnehmen",
ended: "Termin beendet",
};
+66
View File
@@ -0,0 +1,66 @@
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
export type MeetingRole = "host" | "participant";
export type MeetingTokenPayload = {
eventSlug: string;
participantId: string;
name: string;
role: MeetingRole;
exp: number; // unix ms
};
function secret(): string {
const value = process.env.MEETING_SESSION_SECRET;
if (!value) throw new Error("MEETING_SESSION_SECRET is not set");
return value;
}
function base64url(input: Buffer | string): string {
return Buffer.from(input).toString("base64url");
}
function sign(data: string): string {
return createHmac("sha256", secret()).update(data).digest("base64url");
}
/** Issues a short-lived, self-contained, signed token for one meeting session. */
export function createMeetingToken(args: {
eventSlug: string;
name: string;
role: MeetingRole;
ttlMs?: number;
}): { token: string; participantId: string } {
const participantId = randomUUID();
const payload: MeetingTokenPayload = {
eventSlug: args.eventSlug,
participantId,
name: args.name,
role: args.role,
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
};
const body = base64url(JSON.stringify(payload));
const signature = sign(body);
return { token: `${body}.${signature}`, participantId };
}
/** Verifies signature + expiry. Returns null if invalid/expired/tampered. */
export function verifyMeetingToken(token: string): MeetingTokenPayload | null {
const parts = token.split(".");
if (parts.length !== 2) return null;
const [body, signature] = parts;
const expected = sign(body);
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
if (!payload.eventSlug || !payload.participantId || !payload.name || !payload.role) return null;
return payload;
} catch {
return null;
}
}
+124
View File
@@ -0,0 +1,124 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
import type { Event, Offer, Post } from "@/payload-types";
// Wrapped in React's `cache()` so multiple components reading the same data
// during one request (e.g. layout + page) only hit the database once.
//
// IMPORTANT: Payload's Local API defaults `overrideAccess` to `true`, which
// SKIPS each collection's `access.read` check entirely (see
// node_modules/payload/dist/collections/operations/find.js) — it does not
// automatically hide drafts. Every fetcher that feeds a *public* page must
// therefore pass `overrideAccess: false` so the collection's own
// `publishedOrAdmin` access rule actually filters out unpublished documents.
// ---- Angebote ---------------------------------------------------------
export const getOffers = cache(async (): Promise<Offer[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "offers",
sort: "order",
limit: 100,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getOfferBySlug = cache(async (slug: string): Promise<Offer | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "offers",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
overrideAccess: false,
});
return result.docs[0] ?? null;
});
// ---- Termine (Events) --------------------------------------------------
export const getUpcomingEvents = cache(async (limit = 6): Promise<Event[]> => {
const payload = await getCMS();
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await payload.find({
collection: "events",
where: { date: { greater_than_equal: today.toISOString() } },
sort: "date",
limit,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getAllEvents = cache(async (): Promise<Event[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "events",
sort: "-date",
limit: 200,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getEventBySlug = cache(async (slug: string): Promise<Event | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
overrideAccess: false,
});
return result.docs[0] ?? null;
});
/**
* Unlike `getEventBySlug`, this intentionally does NOT filter by publish
* status. Confirmed private bookings create an unpublished/private linked
* Event (see collections/BookingRequests.ts) that must still be reachable
* for the one customer who received its join link + password — "unlisted",
* not "hidden". Only used by the join/beitreten flow, never for listings.
*/
export const getEventForJoin = cache(async (slug: string): Promise<Event | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
});
return result.docs[0] ?? null;
});
// ---- Aktuelles (Posts) --------------------------------------------------
export const getPosts = cache(async (limit = 50): Promise<Post[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "posts",
sort: "-publishDate",
limit,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getPostBySlug = cache(async (slug: string): Promise<Post | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "posts",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
overrideAccess: false,
});
return result.docs[0] ?? null;
});
+11
View File
@@ -0,0 +1,11 @@
import { getPayload } from "payload";
import config from "@payload-config";
/**
* Thin wrapper around Payload's Local API client. `getPayload` already
* memoizes the instance internally (keyed by config), so this is safe to
* call from any Server Component without re-initializing on every request.
*/
export function getCMS() {
return getPayload({ config });
}
+33
View File
@@ -0,0 +1,33 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
import type { About, AktuellesIntro, AngeboteIntro, Booking, Contact, Home } from "@/payload-types";
export const getHomeGlobal = cache(async (): Promise<Home> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "home" });
});
export const getAboutGlobal = cache(async (): Promise<About> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "about" });
});
export const getAngeboteIntroGlobal = cache(async (): Promise<AngeboteIntro> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "angebote-intro" });
});
export const getAktuellesIntroGlobal = cache(async (): Promise<AktuellesIntro> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "aktuelles-intro" });
});
export const getContactGlobal = cache(async (): Promise<Contact> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "contact" });
});
export const getBookingGlobal = cache(async (): Promise<Booking> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "booking" });
});
+21
View File
@@ -0,0 +1,21 @@
import type { Media } from "@/payload-types";
type MediaSize = "thumbnail" | "card" | "hero";
/** Resolves a populated (depth >= 1) media relationship to a usable image URL. */
export function mediaUrl(
value: Media | number | null | undefined,
size?: MediaSize,
): string | undefined {
if (!value || typeof value === "number") return undefined;
if (size) {
const sized = value.sizes?.[size]?.url;
if (sized) return sized;
}
return value.url ?? undefined;
}
export function mediaAlt(value: Media | number | null | undefined): string | undefined {
if (!value || typeof value === "number") return undefined;
return value.alt;
}
+24
View File
@@ -0,0 +1,24 @@
export const siteConfig = {
name: "Anouma",
domain: "https://anouma.org",
title: "Anouma — Prozessbegleitung, Doula & Gemeinschaft",
description:
"Anouma begleitet dich in Lebensphasen, Schwangerschaft und Geburt sowie in Kinder- und Singkreisen — mit Naturverbundenheit, Vertrauen und einem geschützten Raum für deinen eigenen Weg.",
locale: "de_DE",
} as const;
export const mainNav = [
{ title: "Startseite", href: "/" },
{ title: "Über mich", href: "/ueber-mich" },
{ title: "Angebote", href: "/angebote" },
{ title: "Aktuelles", href: "/aktuelles" },
{ title: "Kontakt", href: "/kontakt" },
] as const;
export const ctaNav = { title: "Termin buchen", href: "/termin-buchen" } as const;
export const footerNav = [
{ title: "Impressum", href: "/impressum" },
{ title: "Datenschutz", href: "/datenschutz" },
{ title: "Impressionen", href: "/impressionen" },
] as const;
+183
View File
@@ -0,0 +1,183 @@
/**
* Original Anouma copy, transcribed verbatim from texte.txt and grouped by
* topic. Do not paraphrase content sourced from texte.txt — extend this file
* instead of duplicating strings inline in pages/components.
*/
export const maedchenkreis = {
title: "Mädchenkreis verbunden mit dir, der Natur und dem Kreis",
paragraphs: [
"Der Übergang vom Mädchen zur jungen Frau ist eine besondere Zeit. Vieles beginnt sich zu verändern im Körper, in den Gefühlen und in der Art, sich selbst und die Welt wahrzunehmen.",
"Der Mädchenkreis für Mädchen von 10 bis 14 Jahren schenkt Raum, diesen Weg bewusst und in Gemeinschaft zu erleben. Einen Raum, in dem jede so sein darf, wie sie ist, Fragen ihren Platz haben und die Mädchen sich selbst und einander auf neue Weise kennenlernen können.",
"Gemeinsam beschäftigen wir uns mit Themen wie körperlichen Veränderungen, Weiblichkeit, dem eigenen Frau-Werden und der Frage: Was bedeutet es eigentlich, eine Frau zu sein? Dabei geht es nicht darum, fertige Antworten zu finden, sondern die eigene Wahrnehmung zu stärken und den ganz persönlichen Weg zu entdecken.",
"Kreativität und Naturverbundenheit begleiten uns durch unsere gemeinsame Zeit. Wir arbeiten mit unterschiedlichen Materialien mit Ton, Farben und Naturmaterialien, malen, weben, gestalten und lassen unseren Händen und unserer Fantasie freien Raum. Wir sind draußen in der Natur, nehmen die Jahreszeiten bewusst wahr und feiern gemeinsam Jahreskreisfeste.",
"Mit der Zeit darf so ein vertrauter und geschützter Raum entstehen, in dem die Mädchen sich als Teil einer Gemeinschaft erleben und sicher fühlen können. Ein Kreis, in dem jede mit allem da sein darf, was zu ihr gehört: mit ihren ganz eigenen Gaben und Fähigkeiten, ihrer Lebendigkeit und Freude, aber ebenso mit ihren Unsicherheiten, Ängsten und Schattenseiten. Die Mädchen dürfen erfahren, dass all diese Facetten Teil von ihnen sind und ihren Platz haben.",
"Der Mädchenkreis möchte sie darin bestärken, sich selbst kennenzulernen, sich mit anderen Mädchen verbunden zu fühlen und dem eigenen Frau-Werden mit Neugier und Vertrauen zu begegnen. Frau-Sein darf dabei als etwas Schönes, Lebendiges und Kraftvolles erfahren werden.",
],
} as const;
export const erdenkinder = {
title: "Erdenkinder draußen zuhause",
paragraphs: [
"Die Erdenkinder sind eine Naturgruppe für Kinder von 5 bis 10 Jahren. Wir treffen uns im Wald und wir sind draußen, bei Sonne, Wind und Regen. Die Natur ist dabei nicht nur der Ort, an dem wir uns treffen. Sie ist unser Zuhause, unsere Lehrerin und unsere Begleiterin. Wir möchten erfahren: Wir sind nicht nur in der Natur wir sind ein Teil von ihr. Verbunden mit den Tieren, den Pflanzen, der Erde und miteinander.",
"Gemeinsam ziehen wir los, entdecken und beobachten, sammeln und staunen. Wir singen, bauen, basteln und gestalten mit dem, was wir finden. Wir machen Feuer, kochen gemeinsam und lernen, uns draußen immer vertrauter zu bewegen. Dabei orientieren wir uns am Lauf der Jahreszeiten und erleben, wie sich der Wald und seine Bewohner im Laufe des Jahres verändern.",
"Es geht dabei nicht darum, möglichst viel Wissen über die Natur anzusammeln. Vielmehr dürfen die Kinder mit allen Sinnen erleben: Erde unter den Händen spüren, dem Wind und den Vögeln lauschen, den Duft des Waldes wahrnehmen, Feuer entzünden, gemeinsam etwas erschaffen und immer wieder Neues entdecken. Aus diesen unmittelbaren Erfahrungen kann eine tiefe und selbstverständliche Verbindung zur Natur wachsen.",
"Genauso wichtig wie die Verbindung zur Natur ist uns die Gemeinschaft untereinander. Mit der Zeit darf aus der Gruppe ein kleiner, vertrauter „Stamm“ entstehen. Ein Ort, an dem jedes Kind mit seinen ganz eigenen Gaben, Fähigkeiten und Ideen willkommen und wichtig ist. Jedes Kind darf seinen Platz finden, sich einbringen und erfahren: Ich gehöre dazu und ich kann etwas zu unserer Gemeinschaft beitragen.",
"In dieser gemeinsamen Zeit können Freundschaften wachsen, Vertrauen entstehen und die Kinder erleben, wie schön es ist, miteinander unterwegs zu sein, füreinander da zu sein und voneinander zu lernen verbunden mit sich selbst, miteinander und mit der Erde.",
],
} as const;
export const singkreiseIntro = {
title: "Singkreise miteinander singen, klingen und sein",
paragraphs: [
"Singen verbindet. Mit uns selbst, mit den Menschen um uns herum und mit etwas, das über uns hinausgeht. In meinen Singkreisen geht es nicht darum, „schön“ oder „richtig“ zu singen. Es braucht keine Vorkenntnisse. Im Mittelpunkt steht die Freude daran, die eigene Stimme erklingen zu lassen, miteinander zu schwingen und Gemeinschaft zu erleben.",
],
} as const;
export const singenImKreis = {
title: "Offener Singkreis Lieder für die Erde und das Leben",
paragraphs: [
"Dieser Singkreis ist offen für alle Menschen und findet auf Spendenbasis statt. Wir kommen zusammen und singen Herzenslieder für die Erde, für die Liebe und für all die verschiedenen Facetten unseres Lebens.",
"Das gemeinsame Singen darf dabei mehr sein als Musik. Es kann eine Form der Begegnung und Kommunikation sein, ein gemeinsames Schwingen und Klingen und manchmal vielleicht auch eine Art Gebet. Wir erleben, was entsteht, wenn viele einzelne Stimmen zu einem gemeinsamen Klang werden.",
"Vorkenntnisse braucht es keine. Wer ein Instrument spielt, ist herzlich eingeladen, es mitzubringen und unseren Kreis damit zu bereichern.",
],
} as const;
export const singenFuerSchwangere = {
title: "Singkreis für Schwangere Zeit für dich und dein Baby",
paragraphs: [
"Die Schwangerschaft ist eine ganz besondere Zeit des Wandels und der Verbindung. Dieser Kreis lädt dich dazu ein, dir ganz bewusst Zeit für dich und dein Baby zu nehmen, innezuhalten und diese besondere Lebensphase wahrzunehmen.",
"Wir singen gemeinsam einfache Lieder, Herzens- und Kinderlieder, die dich schon während der Schwangerschaft begleiten können und vielleicht auch später zu vertrauten Liedern für dich und dein Kind werden.",
"Gleichzeitig gibt es Raum, andere schwangere Frauen kennenzulernen, sich auszutauschen und miteinander zu teilen, was euch in dieser Zeit bewegt. Kleine Meditationen und wohltuende Momente des Innehaltens können den Kreis ergänzen.",
"Ein Raum zum Singen, Begegnen, Kraftschöpfen und Verbinden mit dir selbst, deinem Baby und anderen Frauen.",
],
} as const;
export const mamaBabySingkreis = {
title: "Mama-Baby-Singkreis Zeit für euch",
paragraphs: [
"Dieser Singkreis ist für Mamas mit Babys bis zum Krabbelalter. Er möchte euch einen liebevollen Raum schenken, in dem ihr für eine Weile aus dem Alltag aussteigen und euch ganz bewusst Zeit füreinander nehmen könnt.",
"Wir singen einfache Lieder und Kinderlieder, spielen kleine Spiele und entdecken gemeinsam Klänge, Bewegungen und Berührungen. Dabei darf das Singen zu etwas Verbindendem werden die vertraute Stimme der Mama, die Nähe, das gemeinsame Lauschen und Schwingen. Es braucht nichts weiter als euch und die Freude daran, miteinander zu sein.",
"Und auch für dich als Mama soll dieser Kreis ein wohltuender Ort sein. Ein Ort, an dem du einfach ankommen darfst mit allem, was gerade da ist. Es gibt Zeit für Begegnung und Austausch mit anderen Mamas, für gemeinsames Lachen, Teilen und Kennenlernen. Vielleicht entstehen daraus neue Verbindungen, die auch über den Singkreis hinaus tragen.",
"So darf eine kleine Gemeinschaft wachsen, in der Mamas und Babys sich willkommen und getragen fühlen eine besondere gemeinsame Zeit voller Lieder, Nähe, Begegnung und kleiner Momente, die im Alltag manchmal viel zu schnell vorüberziehen.",
],
} as const;
export const prozessbegleitung = {
title: "Prozessbegleitung den eigenen Weg wiederfinden",
intro: [
"Manchmal kommen wir im Leben an einen Punkt, an dem wir nicht mehr weiterwissen. Etwas verändert sich, eine Entscheidung steht an, alte Ängste oder Muster zeigen sich oder das, was bisher getragen hat, fühlt sich plötzlich nicht mehr stimmig an.",
"In solchen Zeiten kann es hilfreich sein, ein Stück des Weges begleitet zu werden.",
"In meiner Prozessbegleitung möchte ich einen Raum schaffen, in dem du mit dem da sein kannst, was dich gerade bewegt. Dabei geht es mir nicht darum, dir fertige Lösungen oder Antworten zu geben. Vielmehr möchte ich dich darin unterstützen, wieder in Kontakt mit deinem eigenen Wissen, deinen Ressourcen und deiner Gestaltungskraft zu kommen.",
"Ich gehe davon aus, dass Alles, was wir für unseren Weg brauchen, bereits in uns angelegt ist. Manchmal braucht es ein Gegenüber, eine neue Perspektive oder einen geschützten Raum, damit wir wieder Zugang dazu finden können.",
],
sections: [
{
heading: "Individuelle Begleitung",
paragraphs: [
"Vielleicht gibt es gerade etwas in deinem Leben, das dich bewegt, verunsichert oder nicht zur Ruhe kommen lässt. Vielleicht stehst du an einer Schwelle, fühlst dich festgefahren, begegnest immer wieder den gleichen Ängsten oder Mustern oder du spürst einfach, dass sich etwas verändern möchte, ohne schon genau zu wissen, was.",
"Du musst damit nicht alleine sein.",
"In der Einzelbegleitung öffne und halte ich einen geschützten Raum für dich und das, was gerade da ist. Einen Raum, in dem du dich zeigen, aussprechen, fühlen und dir selbst begegnen darfst ohne etwas leisten oder bereits eine Antwort haben zu müssen.",
"Ich begleite dich intuitiv und mit unterschiedlichen Zugängen. Manchmal braucht es vor allem ein Gespräch und ein Gegenüber, das wirklich zuhört. Manchmal dürfen wir tiefer schauen, wahrnehmen, was hinter einem Thema liegt, oder über Aufstellungsarbeit, Körperwahrnehmung und andere Impulse neue Zugänge entstehen lassen.",
"Ein wesentlicher Teil meiner Arbeit ist für mich das Vertrauen in die Selbstheilungskräfte und die innere Weisheit jedes Menschen. Ich möchte nicht für dich wissen, wohin dein Weg gehen soll. Vielmehr halte und begleite ich einen Raum, in dem du dich wieder mit dir selbst verbinden und deinen eigenen Impulsen, deiner Kraft und deinen Möglichkeiten begegnen kannst.",
"Denn manchmal geht es weniger darum, etwas im Außen zu finden sondern darum, sich an das zu erinnern, was in einem selbst längst da ist.",
],
},
{
heading: "Die Natur als Begleiterin",
paragraphs: [
"Manche Prozesse führen uns nach draußen. Die Natur kann einen besonderen Raum eröffnen, um stiller zu werden, wahrzunehmen und dem eigenen Inneren auf eine andere Weise zu begegnen.",
"Dabei arbeite ich unter anderem mit Schwellengängen und dem Medizinrad. Übergänge können bewusst begangen, Fragen mit hinaus in die Natur genommen und neue Blickwinkel entdeckt werden. Nicht immer muss eine Antwort mit dem Verstand gefunden werden manchmal entsteht etwas, indem wir wahrnehmen, lauschen und uns wieder mit uns selbst und unserer Umgebung verbinden.",
],
},
{
heading: "Aufstellungsarbeit",
paragraphs: [
"Auch die Aufstellungsarbeit kann Teil der Prozessbegleitung sein sowohl im Einzelsetting als auch in der Gruppe. Sie bietet die Möglichkeit, Beziehungen, innere Dynamiken und wiederkehrende Muster sichtbar zu machen und aus einer anderen Perspektive zu betrachten.",
"Welche Form der Begleitung passend ist, muss nicht vorher feststehen. Wir schauen gemeinsam, was dein Anliegen gerade braucht und welcher Weg sich daraus entwickeln darf.",
],
},
],
} as const;
export const doula = {
title: "Doula-Begleitung getragen durch die Zeit des Werdens",
intro: [
"Mit dem Wunsch nach einem Kind beginnt eine ganz besondere Reise. Eine Zeit, in der Neues wachsen und entstehen darf getragen von Hoffnung, Vorfreude und der leisen Ahnung von all dem, was kommen mag. Ein Weg voller Wandlung, auf dem du dir selbst, deiner Kraft und dem Wunder des Lebens auf ganz neue Weise begegnen darfst.",
"Kinderwunsch, Schwangerschaft, Geburt und Wochenbett sind Zeiten tiefgreifender Veränderung. Nicht nur ein Kind wird geboren. Auch eine Frau wandelt sich, eine Mutter wird geboren und eine Familie findet neu zueinander.",
"Als Doula möchte ich dich auf diesem Weg begleiten von Frau zu Frau, achtsam, liebevoll und in tiefem Vertrauen in die natürlichen Kräfte, die in dir wirken.",
],
sections: [
{
heading: "Schwangerschaft eine Zeit des Wachsens",
paragraphs: [
"Schwangerschaft ist eine Zeit zwischen den Welten. Während in deinem Körper neues Leben heranwächst, darf auch in dir etwas Neues entstehen.",
"Ich möchte dir Raum schenken, langsamer zu werden, nach innen zu lauschen und die Verbindung zu dir und deinem Kind bewusst wahrzunehmen.",
"Dabei darf alles seinen Platz haben: Vorfreude und Vertrauen ebenso wie Fragen, Ängste, Unsicherheiten oder Erfahrungen, die noch nachwirken. Gemeinsam können wir behutsam hinschauen auf das, was dich davon abhält, dich vertrauensvoll auf deinen eigenen Weg einzulassen.",
"Nicht, um eine bestimmte Vorstellung von Schwangerschaft oder Geburt zu erfüllen, sondern damit du immer wieder spüren kannst:",
"Was brauche ich? Was fühlt sich für mich richtig an? Was stärkt mein Vertrauen in mich, meinen Körper und mein Kind?",
],
},
{
heading: "Geburt in deiner weiblichen Kraft",
paragraphs: [
"Gebären ist eine uralte weibliche Kraft.",
"Dein Körper trägt ein tiefes Wissen in sich. Als Doula möchte ich dich darin bestärken, diesem Wissen wieder zu lauschen und deinen ganz eigenen Weg durch die Geburt zu gehen.",
"Während der Geburt bin ich an deiner Seite. Ich halte den Raum, bleibe bei dir, erinnere dich an deine Kraft und unterstütze dich darin, mit dir und deinem Körper verbunden zu bleiben.",
"Jede Geburt ist so einzigartig wie die Frau und das Kind, die sich gemeinsam auf diesen Weg machen. Selbstbestimmte Geburt bedeutet für mich, diesem ganz eigenen Weg Raum zu geben im Vertrauen auf deinen Körper, deine Wahrnehmung und deine Bedürfnisse und mit Menschen an deiner Seite, die dich darin achten und stärken.",
"Selbstbestimmung bedeutet für mich, dich selbst in diesem Geschehen nicht zu verlieren. Wahrgenommen und gehört zu werden, deine Bedürfnisse und Grenzen spüren und ausdrücken zu dürfen und Entscheidungen bewusst mitzutragen.",
"So darf Geburt in welcher Form sie sich auch zeigt möglichst aus einem Gefühl von Verbundenheit, Vertrauen und eigener Kraft heraus erlebt werden.",
],
},
{
heading: "Wochenbett Zeit zum Ankommen",
paragraphs: [
"Nach der Geburt beginnt eine leise und kostbare Zeit.",
"Ein Kind ist angekommen. Eine Mutter ist geboren. Eine Familie ordnet sich neu.",
"Das Wochenbett darf eine Zeit des Rückzugs, der Wärme und des Behütetseins sein. Eine Zeit, in der nicht das Funktionieren im Vordergrund stehen muss, sondern das Ankommen, Kennenlernen und Zusammenwachsen.",
"Auch hier begleite ich dich und eure Familie. Ich höre zu, bin da, stärke und halte Raum damit neben deinem Baby auch du als Frau und Mutter gesehen und umsorgt sein darfst.",
],
},
{
heading: "Wenn ein Kind nicht bleiben kann",
paragraphs: [
"Nicht jedes Kind, das sich auf den Weg zu uns macht, bleibt.",
"Wenn eine Schwangerschaft früh endet oder ihr euer Kind still gebären müsst, kann eine ganz andere Form der Begleitung notwendig werden.",
"Auch auf diesem Weg möchte ich Frauen und Familien nicht alleine lassen. Ich halte einen geschützten Raum für Abschied und Trauer, für Liebe und Verbundenheit, für Stille, Tränen und all das, wofür manchmal noch keine Worte da sind.",
"Vielleicht darf auch ein kleines Ritual entstehen, das hilft, dieses Kind willkommen zu heißen und zugleich wieder gehen zu lassen und ihm einen Platz in der eigenen Geschichte und im Herzen zu geben.",
"Diese Begleitung bei stillen Geburten und frühen Verlusten biete ich kostenfrei an.",
],
},
{
heading: "Meine Begleitung",
paragraphs: [
"Als Doula verstehe ich mich als eine Frau an deiner Seite.",
"Ich möchte dich nicht führen und dir nicht sagen, wie dein Weg aussehen soll. Ich möchte dir zuhören, dich erinnern, dich stärken und einen Raum halten, in dem du deinen eigenen Antworten begegnen kannst.",
"Meine Begleitung kann emotional, körperorientiert und praktisch sein und sich ganz danach richten, was du und deine Familie in diesem Moment brauchen.",
"Dabei führe ich keine medizinischen Tätigkeiten durch und ersetze weder Hebamme noch ärztliche Begleitung. Meine Arbeit versteht sich als ergänzende Begleitung.",
"Im Mittelpunkt steht für mich das Vertrauen in das Leben und in die Kräfte des Werdens und die Erinnerung daran, dass dein Weg zu deinem Kind dein ganz eigener sein darf.",
],
},
],
} as const;
export const ueberMich = {
title: "Über mich mein Weg",
paragraphs: [
"Ich bin Waldorfpädagogin, Musikerin, Prozessbegleiterin, Doula und Mama von drei Kindern. Und doch erzählen all diese Bezeichnungen nur einen kleinen Teil davon, wer ich bin.",
"Mein Leben empfinde ich als eine Reise eine Reise, die mich über viele Wege und Wandlungen immer wieder ein Stück näher zu mir selbst geführt hat. Es gab Zeiten des Wachsens und Aufblühens, des Suchens und Loslassens, des Innehaltens und Neuwerdens. Und mit jedem dieser Schritte durfte ich lernen, immer tiefer in eine ehrliche und liebevolle Beziehung mit mir selbst zu finden meiner inneren Stimme zu lauschen, mir selbst zu vertrauen und zu spüren, was wirklich zu mir gehört.",
"Vielleicht ist genau das für mich der eigentliche Weg: immer mehr die zu werden, die ich im Innersten bin. Mich mit allem anzunehmen, was zu mir gehört mit meiner Kraft und meiner Verletzlichkeit, mit meinen Gaben, meinen Fragen, meinem Licht und meinen Schatten. Und aus dieser Verbundenheit heraus den eigenen Weg immer klarer erkennen und mit Vertrauen gehen zu können.",
"Eine meiner wichtigsten Begleiterinnen auf dieser Reise ist die Natur. In ihr finde ich immer wieder zurück zu etwas Ursprünglichem und Wesentlichem. Der Rhythmus der Jahreszeiten, das Werden und Vergehen, die Stille des Waldes, die Erde unter meinen Füßen erinnern mich daran, dass auch wir Menschen Teil dieser lebendigen Kreisläufe sind. Dass es Zeiten des Wachsens gibt und Zeiten des Rückzugs und dass beides seinen Platz haben darf.",
"Auch die Musik und das Singen begleiten mich schon lange. Im gemeinsamen Klang entsteht für mich eine besondere Form von Verbindung. Manchmal braucht es keine vielen Worte. Wir singen, lauschen, schwingen miteinander und etwas darf sich öffnen und lebendig werden.",
"Und immer wieder waren es auch Menschen, die mich auf meinem Weg begleitet haben. Menschen, die mich gesehen, gehalten, inspiriert und ermutigt haben. Durch sie durfte ich erfahren, wie wertvoll es ist zu erkennen, dass wir alle gemeinsam und doch jeder für sich auf dem Weg sind und was für ein Geschenk es ist, wenn wir das miteinander teilen können. Aus dieser Erfahrung ist in mir der Wunsch gewachsen, auch für andere Menschen solche Räume zu öffnen und ein Stück ihres Weges mitzugehen.",
"Besonders tief geprägt haben mich meine eigenen Schwangerschaften und Geburten. Sie haben mich meinem Körper, meinem Frau-Sein und meiner eigenen Kraft auf eine neue Weise begegnen lassen. Ich durfte erfahren, wie sich Ängste und alte Erfahrungen wandeln können und wie heilsam es sein kann, wieder Vertrauen in sich selbst, den eigenen Körper und das Leben zu finden.",
"Daraus ist mein Wunsch gewachsen, auch andere Frauen zu begleiten sie zu stärken und sie daran zu erinnern, dass ihr eigener Weg bereits in ihnen lebt. Nicht indem ich ihnen sage, wohin sie gehen sollen, sondern indem ich einen Raum halte, in dem sie sich selbst wieder hören, ihrer eigenen Wahrnehmung vertrauen und ihre Kraft spüren können.",
"Und auch den Kindern und Mädchen, die ich begleite, möchte ich etwas davon mitgeben. Ich wünsche mir, dass sie sich mit der Natur verbunden erleben, Gemeinschaft erfahren und zugleich spüren dürfen: Ich bin willkommen, so wie ich bin. Meine Gaben und Fähigkeiten gehören zu mir. Ich darf meine eigene Stimme hören und meinen eigenen Weg entdecken.",
],
closing: {
lead: "Vielleicht ist es genau das, was all die verschiedenen Bereiche meiner Arbeit miteinander verbindet:",
highlight: "Räume zu schaffen, in denen wir uns erinnern dürfen, wer wir sind.",
paragraph:
"Räume für Verbindung mit uns selbst, miteinander und mit der Natur. Räume, in denen wir wachsen und uns wandeln dürfen und dabei immer mehr bei uns selbst ankommen. Und Räume, aus denen heraus der Mut wachsen darf, das eigene Leben verbunden, liebevoll und wahrhaftig zu leben.",
},
} as const;