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
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Inter } from "next/font/google";
import "../(frontend)/globals.css";
// Its own root layout — the video-call UI is intentionally full-bleed and
// dark, without the marketing navbar/footer around it.
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
export const metadata: Metadata = {
title: "Video-Call",
robots: { index: false, follow: false },
};
export default function CallLayout({ children }: { children: ReactNode }) {
return (
<html lang="de" className={`${inter.variable} h-full antialiased`}>
<body className="min-h-full bg-neutral-900">{children}</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { CallRoom } from "@/components/meeting/CallRoom";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export default async function CallPage({ params }: Args) {
const { slug } = await params;
return <CallRoom slug={slug} />;
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { getPostBySlug } from "@/lib/payload/content";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
return { title: post.title, description: post.teaser };
}
export default async function PostDetailPage({ params }: Args) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
const publishDate = new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<>
<PageHeader
eyebrow={publishDate}
title={post.title}
lead={post.teaser}
crumbs={[{ title: "Aktuelles", href: "/aktuelles" }, { title: post.title }]}
/>
<div className="mx-auto -mt-10 max-w-5xl px-6 sm:px-8 lg:px-12">
<div className="relative aspect-[21/9] w-full">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "hero")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
priority
className="h-full w-full"
/>
</div>
</div>
<Section tone="plain">
<div className="mx-auto max-w-2xl">
<RichText data={post.content} />
</div>
</Section>
</>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal";
import { getPosts } from "@/lib/payload/content";
import { getAktuellesIntroGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const intro = await getAktuellesIntroGlobal();
return {
title: intro.title || "Aktuelles",
description: intro.lead || "Neuigkeiten, Termine und Inspiration von Anouma.",
};
}
export default async function AktuellesPage() {
const [intro, posts] = await Promise.all([getAktuellesIntroGlobal(), getPosts()]);
return (
<>
<PageHeader
eyebrow={intro.eyebrow || "Aktuelles"}
title={intro.title || "Neuigkeiten, Termine und Inspiration"}
lead={intro.lead}
crumbs={[{ title: "Aktuelles" }]}
/>
<Section tone="plain">
{posts.length === 0 ? (
<p className="text-lg text-anouma-plum">
Hier erscheinen bald die ersten Neuigkeiten schau gerne wieder vorbei.
</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={Math.min(i * 0.06, 0.3)}>
<Link href={`/aktuelles/${post.slug}`} className="group block">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "card")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
className="h-full w-full transition-transform duration-700 group-hover:scale-[1.03]"
/>
</div>
<p className="mt-4 text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<h2 className="mt-2 font-serif text-xl font-medium text-anouma-plum">{post.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
</Link>
</Reveal>
))}
</div>
)}
</Section>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { getOfferBySlug } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { OFFER_CATEGORIES } from "@/collections/Offers";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const offer = await getOfferBySlug(slug);
if (!offer) return {};
return {
title: offer.title,
description: offer.shortDescription,
};
}
export default async function OfferDetailPage({ params }: Args) {
const { slug } = await params;
const offer = await getOfferBySlug(slug);
if (!offer) notFound();
const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label;
return (
<>
<PageHeader
eyebrow={categoryLabel}
title={offer.title}
lead={offer.shortDescription}
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: offer.title }]}
/>
<div className="mx-auto -mt-10 max-w-5xl px-6 sm:px-8 lg:px-12">
<div className="relative aspect-[21/9] w-full">
<ImagePlaceholder
mood={moodForOffer(offer)}
label={offer.title}
src={mediaUrl(offer.image, "hero")}
alt={mediaAlt(offer.image) ?? offer.title}
shape="soft"
priority
className="h-full w-full"
/>
</div>
</div>
<Section tone="plain">
<div className="mx-auto max-w-2xl">
<RichText data={offer.description} />
</div>
</Section>
<CTA
title="Interesse geweckt?"
lead={`Melde dich gerne für ein unverbindliches Gespräch zu „${offer.title}“.`}
/>
</>
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section, SectionHeading } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { AngebotCard } from "@/components/AngebotCard";
import { Button } from "@/components/Button";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content";
import { getAngeboteIntroGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const intro = await getAngeboteIntroGlobal();
return {
title: intro.title || "Angebote",
description:
intro.lead ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.",
};
}
export default async function AngebotePage() {
const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]);
const groups = groupOffersByCategory(offers);
const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0];
const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0];
const kindergruppen = groups.find((g) => g.category === "kindergruppen");
const singkreise = groups.find((g) => g.category === "singkreise");
return (
<>
<PageHeader
title={intro.title || "Angebote"}
lead={intro.lead}
crumbs={[{ title: "Angebote" }]}
/>
{prozessbegleitung && (
<Section tone="plain">
<div className="grid items-center gap-14 lg:grid-cols-2">
<Reveal>
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none">
<ImagePlaceholder
mood={moodForOffer(prozessbegleitung)}
label={prozessbegleitung.title}
src={mediaUrl(prozessbegleitung.image, "hero")}
alt={mediaAlt(prozessbegleitung.image)}
className="h-full w-full"
/>
</div>
</Reveal>
<Reveal delay={0.1}>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
Für Erwachsene
</p>
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
{prozessbegleitung.title}
</h2>
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
{prozessbegleitung.shortDescription}
</p>
<div className="mt-8">
<Button href={`/angebote/${prozessbegleitung.slug}`}>
{prozessbegleitung.title} entdecken
</Button>
</div>
</Reveal>
</div>
</Section>
)}
{doula && (
<Section tone="cream">
<div className="grid items-center gap-14 lg:grid-cols-2">
<Reveal className="order-2 lg:order-1">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
Kinderwunsch · Schwangerschaft · Geburt · Wochenbett
</p>
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
{doula.title}
</h2>
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
{doula.shortDescription}
</p>
<div className="mt-8">
<Button href={`/angebote/${doula.slug}`}>{doula.title} entdecken</Button>
</div>
</Reveal>
<Reveal delay={0.1} className="order-1 lg:order-2">
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none lg:ml-auto">
<ImagePlaceholder
mood={moodForOffer(doula)}
label={doula.title}
src={mediaUrl(doula.image, "hero")}
alt={mediaAlt(doula.image)}
className="h-full w-full"
/>
</div>
</Reveal>
</div>
</Section>
)}
{kindergruppen && kindergruppen.offers.length > 0 && (
<Section id="kindergruppen" tone="plain">
<SectionHeading eyebrow="Für Kinder" title="Kindergruppen" lead={kindergruppen.label} />
<div className="mt-14 grid gap-6 sm:grid-cols-2">
{kindergruppen.offers.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
size="large"
/>
</Reveal>
))}
</div>
</Section>
)}
{singkreise && singkreise.offers.length > 0 && (
<Section tone="warm">
<SectionHeading eyebrow="Für Herz und Seele" title="Singkreise" />
<div className="mt-14 grid gap-6 sm:grid-cols-3">
{singkreise.offers.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
/>
</Reveal>
))}
</div>
</Section>
)}
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
}
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Singkreise",
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
};
export default async function SingkreisePage() {
const offers = await getOffers();
const singkreise = offers
.filter((offer) => offer.category === "singkreise")
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return (
<>
<PageHeader
eyebrow="Singkreise"
title="Singkreise miteinander singen, klingen und sein"
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
/>
<Section tone="plain">
<div className="grid gap-6 sm:grid-cols-3">
{singkreise.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
/>
</Reveal>
))}
</div>
</Section>
<CTA
title="Deine Stimme erklingen lassen"
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby — melde dich, wenn du einen Kreis besuchen möchtest."
/>
</>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Datenschutz",
description: "Datenschutzerklärung von Anouma.",
robots: { index: false, follow: true },
};
export default async function DatenschutzPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
Diese Seite ist eine strukturelle Vorlage. Bitte die Angaben zu verantwortlicher Stelle,
Hosting, eingesetzten Diensten und Cookies mit den tatsächlich genutzten Tools ergänzen
und rechtlich prüfen lassen, bevor die Seite live geht.
</PlaceholderNote>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter Vor- und Nachname]
<br />
[Platzhalter Anschrift]
<br />
E-Mail: {contact.email}
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
Erhebung und Speicherung personenbezogener Daten
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
Zugriffs). [Platzhalter Details zum verwendeten Hosting-Anbieter ergänzen.]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Kontaktformular</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Wenn du uns per Formular oder E-Mail Anfragen zukommen lässt, werden deine Angaben aus
dem Formular inklusive der von dir dort angegebenen Kontaktdaten zwecks Bearbeitung der
Anfrage bei uns gespeichert. Diese Daten geben wir nicht ohne deine Einwilligung weiter.
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Deine Rechte</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Du hast jederzeit das Recht auf Auskunft, Berichtigung, Löschung und Einschränkung der
Verarbeitung deiner gespeicherten personenbezogenen Daten sowie ein Recht auf
Datenübertragbarkeit und Widerspruch. Wende dich hierzu gerne an {contact.email}.
</p>
</div>
</div>
</Section>
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
@import "tailwindcss";
@theme inline {
/* Anouma color palette */
--color-anouma-mauve-dark: #8b616d;
--color-anouma-rose: #d19ca1;
--color-anouma-rose-soft: #e0b4b1;
--color-anouma-rose-pale: #e5c4bc;
--color-anouma-peach-rose: #ecc8be;
--color-anouma-powder: #efd5c8;
--color-anouma-cream-warm: #efdbcd;
--color-anouma-cream-beige: #f0e0d3;
--color-anouma-cream-light: #f2e2d6;
--color-anouma-sage: #89937c;
--color-anouma-olive: #667052;
--color-anouma-moss: #4f5b45;
--color-anouma-walnut: #765a48;
--color-anouma-caramel: #a47c60;
--color-anouma-sand: #d4c1a5;
--color-anouma-cream-beige-2: #e8dcc8;
--color-anouma-taupe: #a89580;
--color-anouma-dustyrose: #a97070;
--color-anouma-mauve: #8b6f7d;
--color-anouma-plum: #66505f;
--color-background: #fbf6f1;
--color-foreground: #453238;
--font-serif: var(--font-cormorant), "Cormorant Garamond", ui-serif, Georgia, serif;
--font-sans: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif;
--animate-fade-up: fade-up 0.8s ease-out both;
--animate-fade-in: fade-in 1s ease-out both;
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
:root {
--background: #fbf6f1;
--foreground: #453238;
}
* {
scrollbar-color: var(--color-anouma-rose) var(--color-anouma-cream-light);
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
::selection {
background: var(--color-anouma-rose-soft);
color: var(--color-anouma-plum);
}
/* Visible, elegant focus state used across all interactive elements */
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--color-anouma-mauve-dark);
outline-offset: 3px;
border-radius: 2px;
}
.text-balance {
text-wrap: balance;
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal";
import type { ImageMood } from "@/lib/angebote";
export const metadata: Metadata = {
title: "Impressionen",
description: "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.",
};
const gallery: { mood: ImageMood; label: string; span?: string }[] = [
{ mood: "moss", label: "Wald und Naturverbundenheit", span: "sm:row-span-2" },
{ mood: "rose", label: "Gemeinschaft und Begegnung" },
{ mood: "peach", label: "Schwangerschaft und Wachsen" },
{ mood: "sand", label: "Singen und Klang" },
{ mood: "caramel", label: "Kreativität und Hände", span: "sm:row-span-2" },
{ mood: "dustyrose", label: "Mutter und Kind" },
{ mood: "plum", label: "Stille und Innehalten" },
{ mood: "mauve", label: "Räume der Verbindung" },
];
export default function ImpressionenPage() {
return (
<>
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
lead="Diese Galerie ist als Platzhalter-System angelegt — echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
crumbs={[{ title: "Impressionen" }]}
/>
<Section tone="plain">
<div className="grid auto-rows-[14rem] grid-cols-2 gap-4 sm:grid-cols-4">
{gallery.map((item, i) => (
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
<ImagePlaceholder mood={item.mood} label={item.label} shape="soft" className="h-full w-full" />
</Reveal>
))}
</div>
</Section>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Impressum",
description: "Impressum von Anouma gemäß § 5 TMG.",
robots: { index: false, follow: true },
};
export default async function ImpressumPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
Dieses Impressum enthält noch Platzhalterangaben. Bitte vor Veröffentlichung durch die
vollständigen, rechtsverbindlichen Angaben gemäß § 5 TMG ersetzen (und im Zweifel
rechtlich prüfen lassen).
</PlaceholderNote>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter Vor- und Nachname]
<br />
[Platzhalter Straße und Hausnummer]
<br />
[Platzhalter PLZ und Ort]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Kontakt</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
{contact.phone && (
<>
Telefon: {contact.phone}
<br />
</>
)}
E-Mail: {contact.email}
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter Vor- und Nachname, Anschrift wie oben]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Haftungshinweis</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte
externer Links. Für den Inhalt der verlinkten Seiten sind ausschließlich deren
Betreiber verantwortlich.
</p>
</div>
</div>
</Section>
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal";
import { getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const contact = await getContactGlobal();
return {
title: contact.title || "Kontakt",
description: contact.lead || "Ich freue mich, von dir zu hören.",
};
}
export default async function KontaktPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader
eyebrow={contact.eyebrow || "Kontakt"}
title={contact.title || "Ich freue mich, von dir zu hören"}
lead={contact.lead}
crumbs={[{ title: "Kontakt" }]}
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
<Reveal>
<ContactForm toEmail={contact.email} />
</Reveal>
<Reveal delay={0.1} className="space-y-8">
<div className="relative aspect-[4/3] w-full">
<ImagePlaceholder mood="mauve" label="Kontakt" className="h-full w-full" />
</div>
<div className="space-y-3 text-anouma-plum">
<p>
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
E-Mail
</span>
<a
href={`mailto:${contact.email}`}
className="text-lg text-anouma-plum hover:text-anouma-mauve-dark"
>
{contact.email}
</a>
</p>
{contact.phone && (
<p>
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
Telefon
</span>
<span className="text-lg text-anouma-plum">{contact.phone}</span>
</p>
)}
{contact.region && (
<p>
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
Region
</span>
<span className="text-lg text-anouma-plum">{contact.region}</span>
</p>
)}
</div>
</Reveal>
</div>
</Section>
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { PersonalCalendar } from "@/components/booking/PersonalCalendar";
export const metadata: Metadata = { title: "Mein Kalender" };
export default async function KontoKalenderPage() {
const customer = await getCurrentCustomer();
if (!customer) return null;
const bookings = await getCustomerBookings(customer.id);
return (
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Mein Kalender</h2>
<div className="mt-6">
<PersonalCalendar bookings={bookings} />
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Container } from "@/components/Section";
import { LogoutButton } from "@/components/auth/LogoutButton";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
const navItems = [
{ title: "Übersicht", href: "/konto" },
{ title: "Kalender", href: "/konto/kalender" },
{ title: "Meine Termine", href: "/konto/termine" },
{ title: "Profil", href: "/konto/profil" },
];
export default async function KontoLayout({ children }: { children: ReactNode }) {
const customer = await getCurrentCustomer();
if (!customer) redirect("/login?next=/konto");
return (
<section className="bg-anouma-cream-light py-16">
<Container>
<div className="mb-10">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">Mein Konto</p>
<h1 className="mt-2 font-serif text-3xl font-medium text-anouma-plum">Hallo, {customer.name}</h1>
</div>
<div className="grid gap-10 lg:grid-cols-[220px_1fr]">
<nav aria-label="Konto-Navigation" className="flex gap-2 overflow-x-auto lg:flex-col lg:gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="whitespace-nowrap rounded-full px-4 py-2.5 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
>
{item.title}
</Link>
))}
<LogoutButton className="whitespace-nowrap rounded-full px-4 py-2.5 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
</nav>
<div className="min-w-0">{children}</div>
</div>
</Container>
</section>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
export const metadata: Metadata = { title: "Mein Konto" };
export default async function KontoOverviewPage() {
const customer = await getCurrentCustomer();
if (!customer) return null; // Layout already redirects; keeps TS happy.
const bookings = await getCustomerBookings(customer.id);
const pending = bookings.filter((b) => b.status === "pending");
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date());
const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled");
return (
<div className="space-y-10">
<div className="rounded-3xl bg-white p-6">
<h2 className="font-serif text-xl font-medium text-anouma-plum">Deine Daten</h2>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-anouma-plum/60">Name</dt>
<dd className="text-base text-anouma-plum">{customer.name}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-anouma-plum/60">E-Mail</dt>
<dd className="text-base text-anouma-plum">{customer.email}</dd>
</div>
</dl>
</div>
<div>
<div className="flex items-center justify-between">
<h2 className="font-serif text-xl font-medium text-anouma-plum">Offene Buchungsanfragen</h2>
</div>
{pending.length === 0 ? (
<p className="mt-3 text-sm text-anouma-plum/70">Keine offenen Anfragen.</p>
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{pending.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</div>
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Bestätigte Termine</h2>
{confirmed.length === 0 ? (
<p className="mt-3 text-sm text-anouma-plum/70">Noch keine bestätigten Termine.</p>
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{confirmed.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</div>
{past.length > 0 && (
<div>
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Alle Termine (inkl. vergangene) ansehen
</Link>
</div>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { ProfileForm } from "@/components/auth/ProfileForm";
export const metadata: Metadata = { title: "Profil" };
export default async function KontoProfilPage() {
const customer = await getCurrentCustomer();
if (!customer) return null;
return (
<div className="max-w-md">
<h2 className="font-serif text-xl font-medium text-anouma-plum">Profil</h2>
<div className="mt-6 rounded-3xl bg-white p-6">
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
<p className="mt-6 text-xs text-anouma-plum/60">
E-Mail: {customer.email} für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
</p>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
export const metadata: Metadata = { title: "Meine Termine" };
export default async function KontoTerminePage() {
const customer = await getCurrentCustomer();
if (!customer) return null;
const bookings = await getCustomerBookings(customer.id);
return (
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Meine Termine</h2>
{bookings.length === 0 ? (
<p className="mt-3 text-sm text-anouma-plum/70">
Du hast noch keine Terminanfragen gestellt.
</p>
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{bookings.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content";
import "./globals.css";
// Every page under this layout can read live content from Payload, so the
// whole subtree is rendered dynamically rather than statically at build time.
export const dynamic = "force-dynamic";
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
weight: ["400", "500", "600"],
style: ["normal", "italic"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: siteConfig.title,
template: `%s — ${siteConfig.name}`,
},
description: siteConfig.description,
openGraph: {
type: "website",
locale: siteConfig.locale,
url: siteConfig.domain,
siteName: siteConfig.name,
title: siteConfig.title,
description: siteConfig.description,
},
twitter: {
card: "summary_large_image",
title: siteConfig.title,
description: siteConfig.description,
},
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const offers = await getOffers();
return (
<html
lang="de"
data-scroll-behavior="smooth"
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col bg-background text-foreground">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-full focus:bg-anouma-mauve-dark focus:px-5 focus:py-3 focus:text-anouma-cream-light"
>
Zum Inhalt springen
</a>
<Navbar offers={offers} />
<main id="main-content" className="flex-1">
{children}
</main>
<Footer />
</body>
</html>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { LoginForm } from "@/components/auth/LoginForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Anmelden",
};
export default async function LoginPage() {
const customer = await getCurrentCustomer();
if (customer) redirect("/konto");
return (
<>
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
<Section tone="plain">
<div className="mx-auto max-w-md">
<Suspense>
<LoginForm />
</Suspense>
<p className="mt-6 text-center text-sm text-anouma-plum">
Noch kein Konto?{" "}
<Link href="/registrieren" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
Jetzt registrieren
</Link>
</p>
</div>
</Section>
</>
);
}
+223
View File
@@ -0,0 +1,223 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Hero } from "@/components/Hero";
import { Button } from "@/components/Button";
import { Section, SectionHeading } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { getOffers, getPosts, getUpcomingEvents } from "@/lib/payload/content";
import { getHomeGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const home = await getHomeGlobal();
return {
title: { absolute: home.heroTitle || "Willkommen bei Anouma" },
description:
home.heroSupporting ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
};
}
export default async function Home() {
const [home, offers, posts, events] = await Promise.all([
getHomeGlobal(),
getOffers(),
getPosts(3),
getUpcomingEvents(3),
]);
const groups = groupOffersByCategory(offers);
return (
<>
<Hero
eyebrow={home.heroEyebrow ?? undefined}
title={home.heroTitle || "Willkommen bei Anouma"}
mood="rose"
imageSrc={mediaUrl(home.heroImage, "hero")}
imageAlt={mediaAlt(home.heroImage)}
actions={
<>
<Button href="/termin-buchen">Termin buchen</Button>
<Button href="/angebote" variant="secondary">
Angebote entdecken
</Button>
</>
}
>
{home.heroHighlight && <p>{home.heroHighlight}</p>}
{home.heroSupporting && (
<p className="mt-3 text-base text-anouma-plum/90">{home.heroSupporting}</p>
)}
</Hero>
<Section tone="cream">
<SectionHeading
eyebrow="Angebote"
title="Räume, die zu deinem Weg passen"
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen — jedes Angebot ist ein eigener, geschützter Raum."
/>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{groups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
</div>
</Section>
{(home.aboutTitle || home.aboutText) && (
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-2 lg:items-center">
<Reveal>
<div className="relative mx-auto aspect-[4/5] w-full max-w-md lg:max-w-none">
<ImagePlaceholder
mood="mauve"
label="Anouma"
src={mediaUrl(home.aboutImage, "hero")}
alt={mediaAlt(home.aboutImage)}
className="h-full w-full"
/>
</div>
</Reveal>
<Reveal delay={0.1}>
{home.aboutEyebrow && (
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
{home.aboutEyebrow}
</p>
)}
{home.aboutTitle && (
<h2 className="text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
{home.aboutTitle}
</h2>
)}
{home.aboutText && (
<div className="mt-6">
<RichText data={home.aboutText} />
</div>
)}
<div className="mt-8">
<Button href="/ueber-mich" variant="ghost">
Mehr über mich lesen
</Button>
</div>
</Reveal>
</div>
</Section>
)}
{home.values && home.values.length > 0 && (
<Section tone="mauve">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
{wert.quote}
</p>
</Reveal>
))}
</div>
</Section>
)}
{events.length > 0 && (
<Section tone="warm">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
<Button href="/termine" variant="ghost">
Alle Termine
</Button>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{events.map((event, i) => (
<Reveal key={event.slug} delay={i * 0.08}>
<EventTeaserCard event={event} />
</Reveal>
))}
</div>
</Section>
)}
{posts.length > 0 && (
<Section tone="cream">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
<Button href="/aktuelles" variant="ghost">
Alle Neuigkeiten
</Button>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={i * 0.08}>
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">{post.teaser}</p>
</Link>
</Reveal>
))}
</div>
</Section>
)}
<Section tone="plain">
<SectionHeading eyebrow="Impressionen" title="Einblicke in gemeinsame Räume" align="center" className="mx-auto" />
<div className="mt-14 grid grid-cols-2 gap-4 sm:grid-cols-4">
{(["moss", "rose", "peach", "sand"] as const).map((mood, i) => (
<Reveal key={mood} delay={i * 0.06}>
<ImagePlaceholder mood={mood} label="Impression" shape="soft" className="aspect-square" />
</Reveal>
))}
</div>
<div className="mt-10 text-center">
<Link
href="/impressionen"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Impressionen ansehen
</Link>
</div>
</Section>
<CTA
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
lead={
home.ctaLead ||
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
}
/>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { RegisterForm } from "@/components/auth/RegisterForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Konto erstellen",
};
export default async function RegisterPage() {
const customer = await getCurrentCustomer();
if (customer) redirect("/konto");
return (
<>
<PageHeader
eyebrow="Konto"
title="Konto erstellen"
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
crumbs={[{ title: "Konto erstellen" }]}
/>
<Section tone="plain">
<div className="mx-auto max-w-md">
<RegisterForm />
<p className="mt-6 text-center text-sm text-anouma-plum">
Schon ein Konto?{" "}
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
Jetzt anmelden
</Link>
</p>
</div>
</Section>
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section, SectionHeading } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm";
import { Reveal } from "@/components/Reveal";
import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const booking = await getBookingGlobal();
return {
title: booking.title || "Termin buchen",
description: booking.lead || "Kennenlernen & Termine vereinbaren.",
};
}
export default async function TerminBuchenPage() {
const [booking, contact] = await Promise.all([getBookingGlobal(), getContactGlobal()]);
return (
<>
<PageHeader
eyebrow={booking.eyebrow || "Termin buchen"}
title={booking.title || "Kennenlernen & Termine vereinbaren"}
lead={booking.lead}
crumbs={[{ title: "Termin buchen" }]}
/>
{booking.steps && booking.steps.length > 0 && (
<Section tone="cream">
<SectionHeading eyebrow="Ablauf" title="So findest du zu deinem Termin" />
<div className="mt-14 grid gap-8 sm:grid-cols-3">
{booking.steps.map((s, i) => (
<Reveal key={s.id ?? s.title} delay={i * 0.08}>
<p className="font-serif text-5xl font-medium text-anouma-rose">
{String(i + 1).padStart(2, "0")}
</p>
<h3 className="mt-4 font-serif text-xl font-medium text-anouma-plum">{s.title}</h3>
<p className="mt-3 text-base leading-relaxed text-anouma-plum">{s.text}</p>
</Reveal>
))}
</div>
</Section>
)}
<Section tone="plain">
<div className="mx-auto max-w-xl">
<SectionHeading title="Nachricht senden" align="left" />
<div className="mt-10">
<ContactForm
toEmail={contact.email}
subjectPrefix="Terminanfrage über anouma.org"
submitLabel="Terminanfrage senden"
/>
</div>
{booking.formNote && (
<p className="mt-8 text-sm leading-relaxed text-anouma-plum/80">{booking.formNote}</p>
)}
</div>
</Section>
</>
);
}
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { JoinForm } from "@/components/meeting/JoinForm";
import { OrganicBlob } from "@/components/OrganicBlob";
import { getEventForJoin } from "@/lib/payload/content";
import { combineDateAndTime } from "@/lib/meeting/status";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Meeting beitreten",
};
type Args = { params: Promise<{ slug: string }> };
export default async function BeitretenPage({ params }: Args) {
const { slug } = await params;
const event = await getEventForJoin(slug);
if (!event || !event.isOnline) notFound();
const start = combineDateAndTime(event.date, event.startTime);
const dateLabel = start.toLocaleDateString("de-DE", { day: "2-digit", month: "long" });
const timeLabel = start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return (
<section className="relative flex min-h-[80vh] items-center overflow-hidden py-20">
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
<div className="relative mx-auto w-full max-w-md px-6 text-center">
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
ANOUMA
</Link>
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
Dein Termin beginnt bald
</p>
<h1 className="mt-3 font-serif text-3xl font-medium text-anouma-plum">{event.title}</h1>
<p className="mt-2 text-base text-anouma-plum/80">
{dateLabel} · {timeLabel} Uhr
</p>
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
<JoinForm slug={slug} />
</div>
</div>
</section>
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { headers } from "next/headers";
import { getPayload } from "payload";
import config from "@payload-config";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { RegisterForm } from "@/components/meeting/RegisterForm";
import { getEventBySlug } from "@/lib/payload/content";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { formatFullDate, formatTimeRange } from "@/lib/format";
import { EVENT_CATEGORIES } from "@/collections/Events";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, statusLabel } from "@/lib/meeting/status";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const event = await getEventBySlug(slug);
if (!event) return {};
return { title: event.title };
}
export default async function EventDetailPage({ params }: Args) {
const { slug } = await params;
const event = await getEventBySlug(slug);
if (!event) notFound();
const categoryLabel = EVENT_CATEGORIES.find((c) => c.value === event.category)?.label;
const time = formatTimeRange(event.startTime, event.endTime);
let meetingCard = null;
if (event.isOnline) {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: await headers() });
const isHost = Boolean(user);
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime
? combineDateAndTime(event.date, event.endTime)
: new Date(start.getTime() + 60 * 60_000);
const status = getMeetingStatus({
start,
end,
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
const joinable = canJoinMeeting(status);
meetingCard = (
<div className="rounded-3xl bg-anouma-plum p-6 text-center text-anouma-cream-light">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-cream-light/80">
Online-Termin
</p>
{joinable ? (
<Link
href={`/termine/${slug}/beitreten`}
className="mt-4 inline-flex w-full items-center justify-center rounded-full bg-white px-6 py-3 text-sm font-medium text-anouma-plum hover:bg-anouma-cream-light"
>
{statusLabel[status]}
</Link>
) : (
<p className="mt-4 text-base">{statusLabel[status]}</p>
)}
</div>
);
}
return (
<>
<PageHeader
eyebrow={categoryLabel}
title={event.title}
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
<div className="order-2 lg:order-1 space-y-10">
{event.description && <RichText data={event.description} />}
{event.registrationRequired && (
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
<div className="mt-4 max-w-sm">
<RegisterForm slug={slug} />
</div>
</div>
)}
</div>
<div className="order-1 space-y-6 lg:order-2">
{meetingCard}
<div className="relative aspect-[4/3] w-full">
<ImagePlaceholder
mood="rose"
label={event.title}
src={mediaUrl(event.image, "card")}
alt={mediaAlt(event.image) ?? event.title}
className="h-full w-full"
/>
</div>
<dl className="space-y-3 rounded-3xl bg-anouma-cream-light p-6 text-sm">
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Datum</dt>
<dd className="text-base text-anouma-plum">{formatFullDate(event.date)}</dd>
</div>
{time && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Uhrzeit</dt>
<dd className="text-base text-anouma-plum">{time}</dd>
</div>
)}
{event.location && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Ort</dt>
<dd className="text-base text-anouma-plum">{event.location}</dd>
</div>
)}
{event.maxParticipants && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">
Teilnehmerzahl
</dt>
<dd className="text-base text-anouma-plum">max. {event.maxParticipants}</dd>
</div>
)}
{event.registrationRequired && event.registrationInfo && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">
Anmeldung
</dt>
<dd className="text-base text-anouma-plum">{event.registrationInfo}</dd>
</div>
)}
</dl>
</div>
</div>
</Section>
<CTA
title="Dabei sein?"
lead={`Melde dich gerne für „${event.title}“ an oder frag nach freien Plätzen.`}
/>
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { getAllEvents } from "@/lib/payload/content";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Termine",
description: "Aktuelle Termine und Veranstaltungen von Anouma.",
};
export default async function TerminePage() {
const events = await getAllEvents();
const today = new Date();
today.setHours(0, 0, 0, 0);
const upcoming = events.filter((e) => new Date(e.date) >= today).reverse();
const past = events.filter((e) => new Date(e.date) < today);
return (
<>
<PageHeader
eyebrow="Termine"
title="Aktuelle Termine"
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
crumbs={[{ title: "Termine" }]}
/>
<Section tone="plain">
{upcoming.length === 0 ? (
<p className="text-lg text-anouma-plum">
Aktuell sind keine Termine geplant schau gerne bald wieder vorbei oder melde dich
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((event, i) => (
<Reveal key={event.slug} delay={Math.min(i * 0.06, 0.3)}>
<EventTeaserCard event={event} />
</Reveal>
))}
</div>
)}
</Section>
{past.length > 0 && (
<Section tone="warm">
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Vergangene Termine</h2>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{past.map((event) => (
<div key={event.slug} className="opacity-70">
<EventTeaserCard event={event} />
</div>
))}
</div>
</Section>
)}
</>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { OrganicBlob } from "@/components/OrganicBlob";
import { getAboutGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const about = await getAboutGlobal();
return {
title: about.title || "Über mich",
description: "Mein Weg, meine Werte und was mich bewegt.",
};
}
export default async function UeberMichPage() {
const about = await getAboutGlobal();
const hasClosing = about.closingHighlight || about.closingParagraph;
return (
<>
<PageHeader
eyebrow={about.eyebrow || "Über mich"}
title={about.title || "Mein Weg, meine Werte und was mich bewegt"}
crumbs={[{ title: "Über mich" }]}
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[0.85fr_1.15fr] lg:items-start">
<Reveal className="lg:sticky lg:top-28">
<div className="relative mx-auto aspect-[4/5] w-full max-w-sm lg:max-w-none">
<ImagePlaceholder
mood="dustyrose"
label="Portrait"
src={mediaUrl(about.portrait, "hero")}
alt={mediaAlt(about.portrait)}
priority
className="h-full w-full"
/>
</div>
</Reveal>
<div className="max-w-2xl">
<Reveal>
<RichText data={about.body} />
</Reveal>
</div>
</div>
</Section>
{hasClosing && (
<section className="relative overflow-hidden bg-anouma-plum py-24 text-anouma-cream-light">
<OrganicBlob tone="mauve" className="-left-24 top-0 h-96 w-96 opacity-25" />
<div className="relative mx-auto max-w-3xl px-6 text-center sm:px-8">
<Reveal>
{about.closingLead && (
<p className="text-lg text-anouma-cream-light/90">{about.closingLead}</p>
)}
{about.closingHighlight && (
<p className="mt-6 text-balance font-serif text-3xl italic leading-snug sm:text-4xl">
{about.closingHighlight}
</p>
)}
{about.closingParagraph && (
<p className="mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-anouma-cream-light/90">
{about.closingParagraph}
</p>
)}
</Reveal>
</div>
</section>
)}
<CTA
title="Lust, dich kennenzulernen"
lead="Wenn dich mein Weg anspricht, freue ich mich, dich in einem persönlichen Gespräch kennenzulernen."
/>
</>
);
}
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from "next";
import config from "@payload-config";
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap.js";
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap });
export default NotFound;
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from "next";
import config from "@payload-config";
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap.js";
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap });
export default Page;
+52
View File
@@ -0,0 +1,52 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
+19
View File
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Args) {
const { id } = await params;
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
const alternative = booking.proposedAlternative;
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
collection: "booking-requests",
id,
data: {
status: "confirmed",
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
console.error("accept-alternative failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht angenommen werden." }, { status: 500 });
}
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Args) {
const { id } = await params;
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
if (booking.status === "cancelled") {
return NextResponse.json({ ok: true });
}
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error("booking cancel failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
}
}
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
export const dynamic = "force-dynamic";
type Body = {
offerSlug?: unknown;
start?: unknown;
appointmentType?: unknown;
message?: unknown;
};
export async function POST(request: Request) {
let body: Body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
const startISO = typeof body.start === "string" ? body.start : "";
const appointmentType = body.appointmentType === "online" ? "online" : "onsite";
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
if (!offerSlug || !startISO) {
return NextResponse.json({ error: "Angebot und Termin sind erforderlich." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an, um einen Termin anzufragen." }, { status: 401 });
}
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
limit: 1,
overrideAccess: false,
});
const offer = docs[0];
if (!offer || !offer.bookable || !offer.durationMinutes) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
const start = new Date(startISO);
if (Number.isNaN(start.getTime()) || start < new Date()) {
return NextResponse.json({ error: "Bitte wähle einen gültigen, zukünftigen Termin." }, { status: 400 });
}
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(start);
dayEnd.setHours(23, 59, 59, 999);
const slotsByDay = await getAvailableSlots(payload, {
durationMinutes: offer.durationMinutes,
from: dayStart,
to: dayEnd,
});
const key = dayStart.toISOString().slice(0, 10);
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
if (!isStillFree) {
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
}
await payload.create({
collection: "booking-requests",
data: {
user: user.id,
offer: offer.id,
date: start.toISOString(),
startTime: start.toISOString(),
endTime: end.toISOString(),
appointmentType,
status: "pending",
userMessage: message || undefined,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("booking request failed", error);
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
export const dynamic = "force-dynamic";
/** GET /api/booking/slots?offer=<slug>&from=YYYY-MM-DD&to=YYYY-MM-DD */
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const offerSlug = searchParams.get("offer");
const fromParam = searchParams.get("from");
const toParam = searchParams.get("to");
if (!offerSlug) {
return NextResponse.json({ error: "offer ist erforderlich." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
limit: 1,
overrideAccess: false,
});
const offer = docs[0];
if (!offer || !offer.bookable || !offer.durationMinutes) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
const from = fromParam ? new Date(fromParam) : new Date();
from.setHours(0, 0, 0, 0);
const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
to.setHours(23, 59, 59, 999);
// Cap the range so this can't be abused to run an expensive scan.
const maxRangeMs = 62 * 24 * 60 * 60 * 1000;
if (to.getTime() - from.getTime() > maxRangeMs) {
return NextResponse.json({ error: "Zeitraum zu groß." }, { status: 400 });
}
const slotsByDay = await getAvailableSlots(payload, { durationMinutes: offer.durationMinutes, from, to });
const result: Record<string, { start: string; end: string }[]> = {};
for (const [day, slots] of slotsByDay) {
result[day] = slots.map((s) => ({ start: s.start.toISOString(), end: s.end.toISOString() }));
}
return NextResponse.json({ durationMinutes: offer.durationMinutes, slots: result });
} catch (error) {
console.error("booking slots failed", error);
return NextResponse.json({ error: "Verfügbarkeiten konnten nicht geladen werden." }, { status: 500 });
}
}
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { runEventReminders } from "@/lib/meeting/reminders";
export const dynamic = "force-dynamic";
/**
* Intended to be called by an external cron job every few minutes, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
*/
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
}
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const result = await runEventReminders();
return NextResponse.json({ ok: true, ...result });
} catch (error) {
console.error("event-reminders cron failed", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
export const GET = GRAPHQL_PLAYGROUND_GET(config);
+8
View File
@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
export const POST = GRAPHQL_POST(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,97 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; password?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password.trim() : "";
if (!name) {
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
});
const event = docs[0];
if (!event || !event.isOnline) {
return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
}
const { user } = await payload.auth({ headers: request.headers });
const isHost = Boolean(user);
if (!isHost) {
if (!password) {
return NextResponse.json({ error: "Bitte gib das Meeting-Passwort ein." }, { status: 400 });
}
if (!event.meetingPassword || !safeEqual(password, event.meetingPassword)) {
return NextResponse.json({ error: "Das Meeting-Passwort ist nicht korrekt." }, { status: 401 });
}
}
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime
? combineDateAndTime(event.date, event.endTime)
: new Date(start.getTime() + 60 * 60_000);
const status = getMeetingStatus({
start,
end,
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
if (!canJoinMeeting(status)) {
const message =
status === "scheduled"
? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
: "Dieser Termin ist bereits beendet.";
return NextResponse.json({ error: message }, { status: 403 });
}
const { token, participantId } = createMeetingToken({
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
});
return NextResponse.json({
token,
participantId,
role: isHost ? "host" : "participant",
eventTitle: event.title,
});
} catch (error) {
console.error("meeting join failed", error);
return NextResponse.json({ error: "Der Beitritt ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
}
}
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; email?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim() : "";
if (!name || !email || !email.includes("@")) {
return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
});
const event = docs[0];
if (!event) {
return NextResponse.json({ error: "Dieser Termin wurde nicht gefunden." }, { status: 404 });
}
const { docs: existingForEmail } = await payload.find({
collection: "event-registrations",
where: {
and: [
{ event: { equals: event.id } },
{ email: { equals: email } },
{ status: { equals: "registered" } },
],
},
limit: 1,
});
if (existingForEmail[0]) {
return NextResponse.json({ ok: true, alreadyRegistered: true });
}
if (event.maxParticipants) {
const { totalDocs } = await payload.count({
collection: "event-registrations",
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
});
if (totalDocs >= event.maxParticipants) {
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
}
}
await payload.create({
collection: "event-registrations",
data: { event: event.id, name, email, status: "registered" },
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("event registration failed", error);
return NextResponse.json({ error: "Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
}
}
+3
View File
@@ -0,0 +1,3 @@
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
the admin panel uses Payload's own default styling, only the public site
uses the ANOUMA design system (see app/(frontend)/globals.css). */
+31
View File
@@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import type { ServerFunctionClient } from "payload";
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
import React from "react";
import { importMap } from "./admin/importMap.js";
import "./custom.css";
type Args = {
children: React.ReactNode;
};
const serverFunction: ServerFunctionClient = async function (args) {
"use server";
return handleServerFunctions({
...args,
config,
importMap,
});
};
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
);
export default Layout;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+74
View File
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
import { Button } from "@/components/Button";
import { OrganicBlob } from "@/components/OrganicBlob";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { siteConfig } from "@/lib/site";
import "./(frontend)/globals.css";
// Required because the app has two root layouts — (frontend) and (payload) —
// so there is no single layout Next.js could compose a 404 page from.
// See node_modules/next/dist/docs/.../not-found.md ("global-not-found.js").
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
weight: ["400", "500", "600"],
style: ["normal", "italic"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Seite nicht gefunden",
};
export default function GlobalNotFound() {
return (
<html
lang="de"
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col bg-background text-foreground">
{/* This page is always statically prerendered, so it can't read live
CMS data — the mega menu is intentionally omitted here. */}
<Navbar offers={[]} />
<main className="flex-1">
<section className="relative overflow-hidden py-24 sm:py-32">
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
<div className="mx-auto grid max-w-5xl items-center gap-12 px-6 sm:px-8 lg:grid-cols-2 lg:px-12">
<div>
<p className="text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">404</p>
<h1 className="mt-4 text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
Diesen Weg gibt es hier nicht
</h1>
<p className="mt-5 max-w-md text-lg leading-relaxed text-anouma-plum">
Die gesuchte Seite konnte nicht gefunden werden. Vielleicht findest du deinen Weg
über die Startseite oder die Angebote weiter.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Button href="/">Zur Startseite</Button>
<Button href="/angebote" variant="secondary">
Angebote ansehen
</Button>
</div>
</div>
<div className="relative mx-auto aspect-square w-full max-w-sm">
<ImagePlaceholder mood="sand" label="Seite nicht gefunden" className="h-full w-full" />
</div>
</div>
</section>
</main>
{/* Minimal static footer — the full Footer reads live CMS data,
which isn't available on this always-static 404 page. */}
<footer className="bg-anouma-plum py-8 text-center text-sm text-anouma-cream-light">
&copy; {new Date().getFullYear()} {siteConfig.name}
</footer>
</body>
</html>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/impressum", "/datenschutz"],
},
],
sitemap: `${siteConfig.domain}/sitemap.xml`,
};
}
+29
View File
@@ -0,0 +1,29 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
const routes = [
"",
"/ueber-mich",
"/angebote",
"/angebote/prozessbegleitung",
"/angebote/doula-begleitung",
"/angebote/erdenkinder",
"/angebote/maedchenkreis",
"/angebote/singkreise",
"/angebote/singkreise/singen-im-kreis",
"/angebote/singkreise/singen-fuer-schwangere",
"/angebote/singkreise/mama-baby-singkreis",
"/aktuelles",
"/termin-buchen",
"/kontakt",
"/impressionen",
];
export default function sitemap(): MetadataRoute.Sitemap {
return routes.map((route) => ({
url: `${siteConfig.domain}${route}`,
lastModified: new Date(),
changeFrequency: route === "" ? "weekly" : "monthly",
priority: route === "" ? 1 : 0.7,
}));
}