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
+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>";
}