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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}“.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user