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,92 @@
|
||||
/**
|
||||
* Minimal builders for Lexical's SerializedEditorState JSON — just enough to
|
||||
* seed richText fields from plain paragraph/heading strings (see lib/texte.ts).
|
||||
* Shape verified against node_modules/lexical's Serialized*Node types.
|
||||
*/
|
||||
|
||||
type TextNode = {
|
||||
type: "text";
|
||||
version: 1;
|
||||
text: string;
|
||||
format: 0;
|
||||
detail: 0;
|
||||
mode: "normal";
|
||||
style: "";
|
||||
};
|
||||
|
||||
type ParagraphNode = {
|
||||
type: "paragraph";
|
||||
version: 1;
|
||||
children: TextNode[];
|
||||
direction: "ltr";
|
||||
format: "";
|
||||
indent: 0;
|
||||
};
|
||||
|
||||
type HeadingNode = {
|
||||
type: "heading";
|
||||
tag: "h2" | "h3";
|
||||
version: 1;
|
||||
children: TextNode[];
|
||||
direction: "ltr";
|
||||
format: "";
|
||||
indent: 0;
|
||||
};
|
||||
|
||||
function textNode(text: string): TextNode {
|
||||
return { type: "text", version: 1, text, format: 0, detail: 0, mode: "normal", style: "" };
|
||||
}
|
||||
|
||||
function paragraphNode(text: string): ParagraphNode {
|
||||
return {
|
||||
type: "paragraph",
|
||||
version: 1,
|
||||
children: [textNode(text)],
|
||||
direction: "ltr",
|
||||
format: "",
|
||||
indent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function headingNode(text: string, tag: "h2" | "h3" = "h2"): HeadingNode {
|
||||
return {
|
||||
type: "heading",
|
||||
tag,
|
||||
version: 1,
|
||||
children: [textNode(text)],
|
||||
direction: "ltr",
|
||||
format: "",
|
||||
indent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function root(children: (ParagraphNode | HeadingNode)[]) {
|
||||
return {
|
||||
root: {
|
||||
type: "root",
|
||||
children,
|
||||
direction: "ltr" as const,
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function lexicalFromParagraphs(paragraphs: readonly string[]) {
|
||||
return root(paragraphs.map((p) => paragraphNode(p)));
|
||||
}
|
||||
|
||||
export function lexicalFromSections(
|
||||
intro: readonly string[],
|
||||
sections?: readonly { heading: string; paragraphs: readonly string[] }[],
|
||||
) {
|
||||
const children: (ParagraphNode | HeadingNode)[] = intro.map((p) => paragraphNode(p));
|
||||
if (sections) {
|
||||
for (const section of sections) {
|
||||
children.push(headingNode(section.heading));
|
||||
children.push(...section.paragraphs.map((p) => paragraphNode(p)));
|
||||
}
|
||||
}
|
||||
return root(children);
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Seeds the CMS with the original ANOUMA content (the 7 offers from
|
||||
* texte.txt, plus starting text for every editable page). Safe to re-run —
|
||||
* offers are matched and updated by slug, globals are simply overwritten.
|
||||
*
|
||||
* Usage: npm run seed (requires DATABASE_URI + PAYLOAD_SECRET in .env)
|
||||
*
|
||||
* Deliberately does NOT create a Users document — the first admin account is
|
||||
* created through Payload's own "create first user" screen at /admin, so no
|
||||
* password is ever hardcoded here.
|
||||
*/
|
||||
import { getPayload } from "payload";
|
||||
import config from "../payload.config";
|
||||
import {
|
||||
prozessbegleitung,
|
||||
doula,
|
||||
erdenkinder,
|
||||
maedchenkreis,
|
||||
singenImKreis,
|
||||
singenFuerSchwangere,
|
||||
mamaBabySingkreis,
|
||||
ueberMich,
|
||||
} from "../lib/texte";
|
||||
import { lexicalFromParagraphs, lexicalFromSections } from "./lexical";
|
||||
|
||||
async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
const offers = [
|
||||
{
|
||||
slug: "prozessbegleitung",
|
||||
title: "Prozessbegleitung",
|
||||
category: "prozessbegleitung" as const,
|
||||
order: 1,
|
||||
shortDescription: "Persönliche Begleitung in Lebensphasen, Veränderungen und inneren Prozessen.",
|
||||
description: lexicalFromSections(prozessbegleitung.intro, prozessbegleitung.sections),
|
||||
},
|
||||
{
|
||||
slug: "doula-begleitung",
|
||||
title: "Doula-Begleitung",
|
||||
category: "doula-begleitung" as const,
|
||||
order: 2,
|
||||
shortDescription: "Begleitung vor, während und nach der Geburt.",
|
||||
description: lexicalFromSections(doula.intro, doula.sections),
|
||||
},
|
||||
{
|
||||
slug: "erdenkinder",
|
||||
title: "Erdenkinder",
|
||||
category: "kindergruppen" as const,
|
||||
order: 3,
|
||||
shortDescription: "Naturverbunden. Kreativ. Frei. Für Kinder.",
|
||||
description: lexicalFromParagraphs(erdenkinder.paragraphs),
|
||||
},
|
||||
{
|
||||
slug: "maedchenkreis",
|
||||
title: "Mädchenkreis",
|
||||
category: "kindergruppen" as const,
|
||||
order: 4,
|
||||
shortDescription: "Stärkung. Vertrauen. Gemeinschaft. Für Mädchen.",
|
||||
description: lexicalFromParagraphs(maedchenkreis.paragraphs),
|
||||
},
|
||||
{
|
||||
slug: "singen-im-kreis",
|
||||
title: "Singen im Kreis",
|
||||
category: "singkreise" as const,
|
||||
order: 5,
|
||||
shortDescription: "Gemeinsam singen. Verbunden sein.",
|
||||
description: lexicalFromParagraphs(singenImKreis.paragraphs),
|
||||
},
|
||||
{
|
||||
slug: "singen-fuer-schwangere",
|
||||
title: "Singen für Schwangere",
|
||||
category: "singkreise" as const,
|
||||
order: 6,
|
||||
shortDescription: "Lieder, die tragen. Für dich und dein Baby.",
|
||||
description: lexicalFromParagraphs(singenFuerSchwangere.paragraphs),
|
||||
},
|
||||
{
|
||||
slug: "mama-baby-singkreis",
|
||||
title: "Singen für Mamas mit Baby",
|
||||
category: "singkreise" as const,
|
||||
order: 7,
|
||||
shortDescription: "Zeit für dich und dein Baby. Singen. Austauschen. Auftanken.",
|
||||
description: lexicalFromParagraphs(mamaBabySingkreis.paragraphs),
|
||||
},
|
||||
{
|
||||
// A private, bookable single-session offer — not listed in the menu
|
||||
// or on /angebote (see `visibility`), reachable only via its direct
|
||||
// link or a booking Anna creates herself. Content reuses the
|
||||
// "Individuelle Begleitung" section of Prozessbegleitung, which is
|
||||
// exactly this kind of 1:1 session in the original texte.txt.
|
||||
slug: "einzelbegleitung",
|
||||
title: "Einzelbegleitung",
|
||||
category: "prozessbegleitung" as const,
|
||||
order: 8,
|
||||
visibility: "private" as const,
|
||||
bookable: true,
|
||||
price: "auf Anfrage",
|
||||
durationMinutes: 60,
|
||||
shortDescription: "Persönliche Einzelbegleitung — vor Ort oder online.",
|
||||
description: lexicalFromParagraphs(prozessbegleitung.sections[0].paragraphs),
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of offers) {
|
||||
const data = { visibility: "public" as const, ...entry };
|
||||
const existing = await payload.find({
|
||||
collection: "offers",
|
||||
where: { slug: { equals: data.slug } },
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existing.docs[0]) {
|
||||
await payload.update({ collection: "offers", id: existing.docs[0].id, data, draft: false });
|
||||
console.log(` ↺ Angebot aktualisiert: ${data.title}`);
|
||||
} else {
|
||||
await payload.create({ collection: "offers", data, draft: false });
|
||||
console.log(` + Angebot angelegt: ${data.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||||
await payload.updateGlobal({
|
||||
slug: "home",
|
||||
data: {
|
||||
heroEyebrow: "Prozessbegleitung · Doula · Gemeinschaft",
|
||||
heroTitle: "Willkommen bei Anouma",
|
||||
heroHighlight: ueberMich.closing.highlight,
|
||||
heroSupporting: ueberMich.closing.paragraph,
|
||||
aboutEyebrow: "Über mich",
|
||||
aboutTitle: "Mein Weg, meine Werte und was mich bewegt",
|
||||
aboutText: lexicalFromParagraphs(ueberMich.paragraphs.slice(0, 2)),
|
||||
valuesEyebrow: "Philosophie",
|
||||
valuesTitle: "Räume, in denen wir uns erinnern dürfen, wer wir sind",
|
||||
values: [
|
||||
{
|
||||
title: "Vertrauen",
|
||||
quote: "Alles, was wir für unseren Weg brauchen, ist bereits in uns angelegt.",
|
||||
},
|
||||
{
|
||||
title: "Verbindung",
|
||||
quote: "Räume für Verbindung — mit uns selbst, miteinander und mit der Natur.",
|
||||
},
|
||||
{ title: "Natur", quote: "Wir sind nicht nur in der Natur — wir sind ein Teil von ihr." },
|
||||
],
|
||||
ctaTitle: "Kennenlernen & Termine vereinbaren",
|
||||
ctaLead:
|
||||
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch.",
|
||||
},
|
||||
});
|
||||
console.log(" ↺ Global „Startseite“ gesetzt");
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: "about",
|
||||
data: {
|
||||
eyebrow: "Über mich",
|
||||
title: "Mein Weg, meine Werte und was mich bewegt",
|
||||
body: lexicalFromParagraphs(ueberMich.paragraphs),
|
||||
closingLead: ueberMich.closing.lead,
|
||||
closingHighlight: ueberMich.closing.highlight,
|
||||
closingParagraph: ueberMich.closing.paragraph,
|
||||
},
|
||||
});
|
||||
console.log(" ↺ Global „Über mich“ gesetzt");
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: "angebote-intro",
|
||||
data: {
|
||||
title: "Angebote",
|
||||
lead: "Jeder Raum ist anders — und darf so genau das sein, was gerade gebraucht wird. Ein Überblick über meine Begleitung für Erwachsene, Kinder und Gemeinschaft.",
|
||||
},
|
||||
});
|
||||
console.log(" ↺ Global „Angebote (Einleitung)“ gesetzt");
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: "aktuelles-intro",
|
||||
data: {
|
||||
eyebrow: "Aktuelles",
|
||||
title: "Neuigkeiten, Termine und Inspiration",
|
||||
lead: "Hier findest du künftig aktuelle Termine, Ankündigungen und Impulse rund um die Angebote von Anouma.",
|
||||
},
|
||||
});
|
||||
console.log(" ↺ Global „Aktuelles (Einleitung)“ gesetzt");
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: "contact",
|
||||
data: {
|
||||
eyebrow: "Kontakt",
|
||||
title: "Ich freue mich, von dir zu hören",
|
||||
lead: "Ob Frage, Anliegen oder der Wunsch nach einem Kennenlerngespräch — schreib mir gerne.",
|
||||
// Placeholder contact details — replace with the real ones in the admin.
|
||||
email: "hallo@anouma.org",
|
||||
phone: "+49 000 00 00 00",
|
||||
region: "Platzhalter — Ort / Region",
|
||||
},
|
||||
});
|
||||
console.log(" ↺ Global „Kontakt“ gesetzt (mit Platzhalter-Kontaktdaten — bitte im Admin ersetzen)");
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: "booking",
|
||||
data: {
|
||||
eyebrow: "Termin buchen",
|
||||
title: "Kennenlernen & Termine vereinbaren",
|
||||
lead: "Der erste Schritt ist oft der wichtigste. Melde dich gerne — unverbindlich und in deinem Tempo.",
|
||||
steps: [
|
||||
{
|
||||
title: "Nachricht schreiben",
|
||||
text: "Schildere kurz dein Anliegen und welches Angebot dich interessiert — über das Formular unten oder direkt per E-Mail.",
|
||||
},
|
||||
{
|
||||
title: "Kennenlernen",
|
||||
text: "In einem ersten, unverbindlichen Gespräch schauen wir gemeinsam, was du gerade brauchst und welcher Weg passt.",
|
||||
},
|
||||
{
|
||||
title: "Termin vereinbaren",
|
||||
text: "Passt es für euch beide, vereinbaren wir gemeinsam die weiteren Termine.",
|
||||
},
|
||||
],
|
||||
formNote:
|
||||
"Aktuell öffnet dieses Formular dein E-Mail-Programm. Sobald ein Buchungstool (z. B. für Online-Terminvergabe) ausgewählt ist, kann es hier eingebunden werden.",
|
||||
},
|
||||
});
|
||||
console.log(" ↺ Global „Termin buchen“ gesetzt");
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const payload = await getPayload({ config });
|
||||
|
||||
console.log("Angebote:");
|
||||
await seedOffers(payload);
|
||||
|
||||
console.log("Seiteninhalte:");
|
||||
await seedGlobals(payload);
|
||||
|
||||
console.log("\nFertig. Richte jetzt unter /admin den ersten Admin-Zugang ein (dort wird kein Passwort vorgegeben).");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user