Add email verification, personal calendar feed, and full SEO implementation

- Customer accounts now require email verification (hashed, single-use,
  time-limited tokens) before they can request/confirm bookings, with
  resend flows on login/account/booking widget and rate limiting.
- Admins get a private, rotatable iCalendar (ICS) subscription feed of
  their confirmed bookings and public events, timezone-correct for
  Europe/Berlin including DST, never exposing meeting passwords.
- Adds a full SEO layer: per-page canonical/OG/Twitter metadata with
  CMS-editable overrides and content-derived fallbacks, a dynamic
  sitemap.xml and robots.txt driven by real published content, JSON-LD
  (Organization/LocalBusiness, WebSite, WebPage, BreadcrumbList, Service,
  Event, BlogPosting) that never fabricates data, and a CMS-managed
  redirect table for changed slugs.
- Global ANOUMA-naming audit: the brand name is never used to label
  personal account/calendar areas anywhere in the app, CMS, or emails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:57:31 +02:00
co-authored by Claude Sonnet 5
parent 50c39a70e0
commit 1cd15aff25
72 changed files with 2835 additions and 257 deletions
+49 -4
View File
@@ -1,26 +1,61 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect, redirect } 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";
import { getSEOSettingsGlobal } from "@/lib/payload/globals";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, articleJsonLd } from "@/lib/seo/jsonld";
import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
async function resolvePostOrRedirect(slug: string) {
const post = await getPostBySlug(slug);
if (post) return post;
const match = await resolveRedirect(`/aktuelles/${slug}`);
if (match) {
if (match.permanent) permanentRedirect(match.to);
redirect(match.to);
}
return null;
}
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
const [post, seoSettings] = await Promise.all([getPostBySlug(slug), getSEOSettingsGlobal()]);
if (!post) return {};
return { title: post.title, description: post.teaser };
const resolved = resolveSeo({
seo: post.seo,
fallbackTitle: `${post.title} ${siteConfig.name}`,
fallbackDescription: () => post.teaser,
fallbackImage: post.coverImage,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: `/aktuelles/${post.slug}`,
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
type: "article",
});
}
export default async function PostDetailPage({ params }: Args) {
const { slug } = await params;
const post = await getPostBySlug(slug);
const post = await resolvePostOrRedirect(slug);
if (!post) notFound();
const publishDate = new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
@@ -28,9 +63,19 @@ export default async function PostDetailPage({ params }: Args) {
month: "long",
year: "numeric",
});
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd
data={articleJsonLd({
post,
description: post.seo?.description || post.teaser || excerptFromRichText(post.content),
imageUrl: mediaUrl(post.coverImage, "hero"),
siteUrl,
providerName: siteConfig.name,
})}
/>
<PageHeader
eyebrow={publishDate}
title={post.title}
+29 -6
View File
@@ -5,24 +5,47 @@ 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 { getAktuellesIntroGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Neuigkeiten, Termine und Inspiration von Anouma.";
export async function generateMetadata(): Promise<Metadata> {
const intro = await getAktuellesIntroGlobal();
return {
title: intro.title || "Aktuelles",
description: intro.lead || "Neuigkeiten, Termine und Inspiration von Anouma.",
};
const [intro, seoSettings] = await Promise.all([getAktuellesIntroGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: intro.seo,
fallbackTitle: "Aktuelles",
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/aktuelles",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
}
export default async function AktuellesPage() {
const [intro, posts] = await Promise.all([getAktuellesIntroGlobal(), getPosts()]);
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd
data={webPageJsonLd({
name: intro.title || "Aktuelles",
description: intro.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/aktuelles`,
})}
/>
<PageHeader
eyebrow={intro.eyebrow || "Aktuelles"}
title={intro.title || "Neuigkeiten, Termine und Inspiration"}
+54 -8
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect, redirect } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
@@ -11,31 +11,77 @@ import { moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { OFFER_CATEGORIES } from "@/collections/Offers";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getSEOSettingsGlobal } from "@/lib/payload/globals";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, serviceJsonLd } from "@/lib/seo/jsonld";
import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
async function resolveOfferOrRedirect(slug: string) {
const offer = await getOfferBySlug(slug);
if (offer) return offer;
const match = await resolveRedirect(`/angebote/${slug}`);
if (match) {
if (match.permanent) permanentRedirect(match.to);
redirect(match.to);
}
return null;
}
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const offer = await getOfferBySlug(slug);
const [offer, seoSettings] = await Promise.all([getOfferBySlug(slug), getSEOSettingsGlobal()]);
if (!offer) return {};
return {
title: offer.title,
description: offer.shortDescription,
};
const resolved = resolveSeo({
seo: offer.seo,
fallbackTitle: `${offer.title} ${siteConfig.name}`,
fallbackDescription: () => offer.shortDescription,
fallbackImage: offer.image,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: `/angebote/${offer.slug}`,
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
// Private single-session offers (e.g. "Einzelbegleitung") stay reachable
// via their direct link but must never be indexed or listed.
noindex: offer.visibility === "private",
});
}
export default async function OfferDetailPage({ params }: Args) {
const { slug } = await params;
const offer = await getOfferBySlug(slug);
const offer = await resolveOfferOrRedirect(slug);
if (!offer) notFound();
const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label;
const customer = offer.bookable ? await getCurrentCustomer() : null;
const siteUrl = await getSiteUrl();
return (
<>
{offer.visibility !== "private" && (
<JsonLd
data={serviceJsonLd({
offer,
description: offer.seo?.description || excerptFromRichText(offer.description) || offer.shortDescription,
imageUrl: mediaUrl(offer.image, "hero"),
siteUrl,
providerName: siteConfig.name,
})}
/>
)}
<PageHeader
eyebrow={categoryLabel}
title={offer.title}
@@ -81,7 +127,7 @@ export default async function OfferDetailPage({ params }: Args) {
<Section tone="cream">
<div className="mx-auto max-w-3xl">
<h2 className="mb-6 text-center font-serif text-3xl font-medium text-anouma-plum">Termin auswählen</h2>
<BookingWidget offerSlug={offer.slug!} isLoggedIn={Boolean(customer)} />
<BookingWidget offerSlug={offer.slug!} isLoggedIn={Boolean(customer)} isVerified={Boolean(customer?.emailVerified)} />
</div>
</Section>
) : (
+31 -8
View File
@@ -7,25 +7,41 @@ 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 { getAngeboteIntroGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION =
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.";
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.",
};
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: intro.seo,
fallbackTitle: `Begleitung für dich und deine Familie ${siteConfig.name}`,
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/angebote",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
}
export default async function AngebotePage() {
const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]);
const groups = groupOffersByCategory(offers);
const siteUrl = await getSiteUrl();
const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0];
const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0];
@@ -34,6 +50,13 @@ export default async function AngebotePage() {
return (
<>
<JsonLd
data={webPageJsonLd({
name: intro.title || "Angebote",
description: intro.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/angebote`,
})}
/>
<PageHeader
title={intro.title || "Angebote"}
lead={intro.lead}
+9 -4
View File
@@ -7,13 +7,18 @@ import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaUrl } from "@/lib/payload/media";
import { buildMetadata } from "@/lib/seo/metadata";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Singkreise",
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({
title: `Singkreise ${siteConfig.name}`,
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
path: "/angebote/singkreise",
});
}
export default async function SingkreisePage() {
const offers = await getOffers();
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { verifyEmailToken } from "@/lib/auth/verifyEmailToken";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "E-Mail bestätigen", robots: { index: false, follow: false } };
type Args = { params: Promise<{ token: string }> };
export default async function VerifyEmailPage({ params }: Args) {
const { token } = await params;
const verified = await verifyEmailToken(token);
return (
<>
<PageHeader
eyebrow="Konto"
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
crumbs={[{ title: "E-Mail bestätigen" }]}
/>
<Section tone="plain">
<div className="mx-auto max-w-md text-center">
{verified ? (
<>
<p className="text-base leading-relaxed text-anouma-plum">Dein Konto ist jetzt aktiviert.</p>
<Link
href="/konto"
className="mt-6 inline-flex rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
Zum Konto
</Link>
</>
) : (
<>
<p className="text-base leading-relaxed text-anouma-plum">
Dieser Verifizierungslink ist ungültig oder abgelaufen.
</p>
<div className="mt-6 flex flex-col items-center gap-4">
<ResendVerificationButton variant="email-prompt" triggerLabel="Neuen Link anfordern" />
<Link href="/login" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Zur Anmeldung
</Link>
</div>
</>
)}
</div>
</Section>
</>
);
}
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { getCMS } from "@/lib/payload/getPayload";
import { hashCalendarFeedToken } from "@/lib/calendar/feedToken";
import { buildICSCalendar } from "@/lib/calendar/ics";
import { getAdminFeedICSEvents } from "@/lib/calendar/adminFeed";
export const dynamic = "force-dynamic";
const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
type Args = { params: Promise<{ tokenFile: string }> };
/**
* GET /calendar/<token>.ics — a private iCalendar feed, subscribable from
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
* itself is the only credential (there's no login here), so it's treated
* like a secret: looked up by hash only, never logged, and the response is
* marked non-cacheable-by-proxies and non-indexable.
*/
export async function GET(_request: Request, { params }: Args) {
const { tokenFile } = await params;
if (!tokenFile.endsWith(".ics")) {
return new NextResponse("Not found", { status: 404 });
}
const token = tokenFile.slice(0, -".ics".length);
if (!TOKEN_SHAPE.test(token)) {
return new NextResponse("Not found", { status: 404 });
}
const payload = await getCMS();
const hash = hashCalendarFeedToken(token);
const { docs } = await payload.find({
collection: "users",
where: { calendarFeedTokenHash: { equals: hash } },
limit: 1,
});
// Section 15 / later extension: once normal user accounts also get a
// personal feed, a matching lookup against "customers" would go here,
// returning that customer's own bookings instead of the admin feed below.
const owner = docs[0];
if (!owner) {
return new NextResponse("Not found", { status: 404, headers: { "X-Robots-Tag": "noindex, nofollow" } });
}
const events = await getAdminFeedICSEvents(payload);
const ics = buildICSCalendar({ calendarName: "Persönlicher Kalender", events });
return new NextResponse(ics, {
status: 200,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="kalender.ics"',
"X-Robots-Tag": "noindex, nofollow",
"Cache-Control": "private, no-store",
},
});
}
+10 -5
View File
@@ -3,14 +3,19 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Datenschutz",
description: "Datenschutzerklärung von Anouma.",
robots: { index: false, follow: true },
};
export async function generateMetadata(): Promise<Metadata> {
const siteUrl = await getSiteUrl();
return {
title: "Datenschutz",
description: "Datenschutzerklärung von Anouma.",
robots: { index: false, follow: true },
alternates: { canonical: `${siteUrl}/datenschutz` },
};
}
export default async function DatenschutzPage() {
const contact = await getContactGlobal();
+14 -5
View File
@@ -4,11 +4,17 @@ import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal";
import type { ImageMood } from "@/lib/angebote";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const metadata: Metadata = {
title: "Impressionen",
description: "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.",
};
export const dynamic = "force-dynamic";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
}
const gallery: { mood: ImageMood; label: string; span?: string }[] = [
{ mood: "moss", label: "Wald und Naturverbundenheit", span: "sm:row-span-2" },
@@ -21,9 +27,12 @@ const gallery: { mood: ImageMood; label: string; span?: string }[] = [
{ mood: "mauve", label: "Räume der Verbindung" },
];
export default function ImpressionenPage() {
export default async function ImpressionenPage() {
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd data={webPageJsonLd({ name: "Impressionen", description: DESCRIPTION, url: `${siteUrl}/impressionen` })} />
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
+12 -5
View File
@@ -3,14 +3,21 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Impressum",
description: "Impressum von Anouma gemäß § 5 TMG.",
robots: { index: false, follow: true },
};
export async function generateMetadata(): Promise<Metadata> {
const siteUrl = await getSiteUrl();
return {
title: "Impressum",
description: "Impressum von Anouma gemäß § 5 TMG.",
// noindex, but still crawlable — legal boilerplate deliberately kept
// out of search results without cutting off the internal links on it.
robots: { index: false, follow: true },
alternates: { canonical: `${siteUrl}/impressum` },
};
}
export default async function ImpressumPage() {
const contact = await getContactGlobal();
+30 -6
View File
@@ -4,23 +4,47 @@ 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";
import { getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören.";
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.",
};
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: contact.seo,
fallbackTitle: `Kontakt ${siteConfig.name}`,
fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/kontakt",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
}
export default async function KontaktPage() {
const contact = await getContactGlobal();
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd
data={webPageJsonLd({
name: contact.title || "Kontakt",
description: contact.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/kontakt`,
})}
/>
<PageHeader
eyebrow={contact.eyebrow || "Kontakt"}
title={contact.title || "Ich freue mich, von dir zu hören"}
+13
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
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 { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
// Applies to every /konto/* page at once — private account area, never indexable.
export const metadata: Metadata = { robots: { index: false, follow: false } };
const navItems = [
{ title: "Übersicht", href: "/konto" },
@@ -25,6 +29,15 @@ export default async function KontoLayout({ children }: { children: ReactNode })
<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>
{!customer.emailVerified && (
<div className="mb-8 rounded-2xl bg-anouma-cream-beige p-5">
<p className="text-sm font-medium text-anouma-plum">Bitte bestätige zuerst deine E-Mail-Adresse.</p>
<p className="mt-1 text-sm text-anouma-plum/80">
Erst danach kannst du Termine anfragen. Prüfe dein Postfach oder fordere unten eine neue E-Mail an.
</p>
<ResendVerificationButton variant="session" className="mt-3" />
</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) => (
+19 -1
View File
@@ -5,6 +5,9 @@ import { Footer } from "@/components/Footer";
import { siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getBookingSettingsGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld";
import "./globals.css";
// Every page under this layout can read live content from Payload, so the
@@ -43,10 +46,19 @@ export const metadata: Metadata = {
title: siteConfig.title,
description: siteConfig.description,
},
// Configurable via env only — never a hardcoded verification ID (see .env.example).
...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}),
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [offers, customer] = await Promise.all([getOffers(), getCurrentCustomer()]);
const [offers, customer, seoSettings, bookingSettings, contact, siteUrl] = await Promise.all([
getOffers(),
getCurrentCustomer(),
getSEOSettingsGlobal(),
getBookingSettingsGlobal(),
getContactGlobal(),
getSiteUrl(),
]);
return (
<html
@@ -55,6 +67,12 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col bg-background text-foreground">
<JsonLd
data={[
websiteJsonLd(siteUrl),
organizationOrLocalBusinessJsonLd({ settings: seoSettings, booking: bookingSettings, contact, siteUrl }),
]}
/>
<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"
+1
View File
@@ -11,6 +11,7 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Anmelden",
robots: { index: false, follow: false },
};
export default async function LoginPage() {
+33 -6
View File
@@ -10,20 +10,39 @@ 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 { getHomeGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { siteConfig } from "@/lib/site";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const home = await getHomeGlobal();
return {
title: { absolute: home.heroTitle || "Willkommen bei Anouma" },
description:
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: home.seo,
fallbackTitle: `${siteConfig.name} Begleitung für dich und deine Familie`,
fallbackDescription: () =>
home.heroSupporting ||
seoSettings.defaultDescription ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
};
fallbackImage: home.heroImage,
defaultOgImage: seoSettings.defaultOgImage,
});
const meta = await buildMetadata({
title: resolved.title,
description: resolved.description,
path: "",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
// Homepage bypasses the "%s — Anouma" title template — the brand is
// already part of the fallback/CMS title itself, avoiding "Anouma — Anouma".
return { ...meta, title: { absolute: resolved.title } };
}
export default async function Home() {
@@ -34,9 +53,17 @@ export default async function Home() {
getUpcomingEvents(3),
]);
const groups = groupOffersByCategory(offers);
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd
data={webPageJsonLd({
name: home.heroTitle || "Willkommen bei Anouma",
description: home.heroSupporting || siteConfig.description,
url: siteUrl,
})}
/>
<Hero
eyebrow={home.heroEyebrow ?? undefined}
title={home.heroTitle || "Willkommen bei Anouma"}
+1
View File
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Konto erstellen",
robots: { index: false, follow: false },
};
export default async function RegisterPage() {
+30 -6
View File
@@ -4,27 +4,51 @@ import { Section, SectionHeading } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm";
import { AngebotCard } from "@/components/AngebotCard";
import { Reveal } from "@/components/Reveal";
import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals";
import { getBookingGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getOffers } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren.";
export async function generateMetadata(): Promise<Metadata> {
const booking = await getBookingGlobal();
return {
title: booking.title || "Termin buchen",
description: booking.lead || "Kennenlernen & Termine vereinbaren.",
};
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: booking.seo,
fallbackTitle: `Termin buchen ${siteConfig.name}`,
fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/termin-buchen",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
}
export default async function TerminBuchenPage() {
const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]);
const bookableOffers = offers.filter((offer) => offer.bookable);
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd
data={webPageJsonLd({
name: booking.title || "Termin buchen",
description: booking.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/termin-buchen`,
})}
/>
<PageHeader
eyebrow={booking.eyebrow || "Termin buchen"}
title={booking.title || "Kennenlernen & Termine vereinbaren"}
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Meeting beitreten",
robots: { index: false, follow: false },
};
type Args = { params: Promise<{ slug: string }> };
+50 -4
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect, redirect } from "next/navigation";
import Link from "next/link";
import { headers } from "next/headers";
import { getPayload } from "payload";
@@ -11,29 +11,64 @@ import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { RegisterForm } from "@/components/meeting/RegisterForm";
import { getEventBySlug } from "@/lib/payload/content";
import { getBookingSettingsGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
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";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, eventJsonLd } from "@/lib/seo/jsonld";
import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
async function resolveEventOrRedirect(slug: string) {
const event = await getEventBySlug(slug);
if (event) return event;
const match = await resolveRedirect(`/termine/${slug}`);
if (match) {
if (match.permanent) permanentRedirect(match.to);
redirect(match.to);
}
return null;
}
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const event = await getEventBySlug(slug);
const [event, seoSettings] = await Promise.all([getEventBySlug(slug), getSEOSettingsGlobal()]);
if (!event) return {};
return { title: event.title };
const resolved = resolveSeo({
seo: event.seo,
fallbackTitle: `${event.title} ${siteConfig.name}`,
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
fallbackImage: event.image,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: `/termine/${event.slug}`,
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
}
export default async function EventDetailPage({ params }: Args) {
const { slug } = await params;
const event = await getEventBySlug(slug);
const event = await resolveEventOrRedirect(slug);
if (!event) notFound();
const categoryLabel = EVENT_CATEGORIES.find((c) => c.value === event.category)?.label;
const time = formatTimeRange(event.startTime, event.endTime);
const [siteUrl, bookingSettings] = await Promise.all([getSiteUrl(), getBookingSettingsGlobal()]);
let meetingCard = null;
if (event.isOnline) {
@@ -76,6 +111,17 @@ export default async function EventDetailPage({ params }: Args) {
return (
<>
<JsonLd
data={eventJsonLd({
event,
description: event.seo?.description || excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
imageUrl: mediaUrl(event.image, "hero"),
joinUrl: event.isOnline ? `${siteUrl}/termine/${slug}/beitreten` : undefined,
siteUrl,
providerName: siteConfig.name,
booking: bookingSettings,
})}
/>
<PageHeader
eyebrow={categoryLabel}
title={event.title}
+10 -4
View File
@@ -4,13 +4,17 @@ import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { getAllEvents } from "@/lib/payload/content";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Termine",
description: "Aktuelle Termine und Veranstaltungen von Anouma.",
};
const DESCRIPTION = "Aktuelle Termine und Veranstaltungen von Anouma.";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ title: "Termine", description: DESCRIPTION, path: "/termine" });
}
export default async function TerminePage() {
const events = await getAllEvents();
@@ -18,9 +22,11 @@ export default async function TerminePage() {
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);
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd data={webPageJsonLd({ name: "Termine", description: DESCRIPTION, url: `${siteUrl}/termine` })} />
<PageHeader
eyebrow="Termine"
title="Aktuelle Termine"
+30 -6
View File
@@ -6,25 +6,49 @@ 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 { getAboutGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
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.",
};
const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: about.seo,
fallbackTitle: `Über mich ${siteConfig.name}`,
fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.",
fallbackImage: about.portrait,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/ueber-mich",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
}
export default async function UeberMichPage() {
const about = await getAboutGlobal();
const hasClosing = about.closingHighlight || about.closingParagraph;
const siteUrl = await getSiteUrl();
return (
<>
<JsonLd
data={webPageJsonLd({
name: about.title || "Über mich",
description: excerptFromRichText(about.body) || "Mein Weg, meine Werte und was mich bewegt.",
url: `${siteUrl}/ueber-mich`,
})}
/>
<PageHeader
eyebrow={about.eyebrow || "Über mich"}
title={about.title || "Mein Weg, meine Werte und was mich bewegt"}
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } from "@/lib/calendar/feedToken";
export const dynamic = "force-dynamic";
/**
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
* returned exactly once in this response and can never be retrieved again
* — generating a new one immediately invalidates whichever link was issued
* before (see collections/Users.ts).
*/
export async function POST(request: Request) {
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "users") {
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
}
const token = generateCalendarFeedToken();
await payload.update({
collection: "users",
id: user.id,
data: { calendarFeedTokenHash: hashCalendarFeedToken(token) },
});
return NextResponse.json({ url: calendarFeedUrl(token) });
} catch (error) {
console.error("calendar feed regenerate failed", error);
return NextResponse.json({ error: "Der Kalender-Link konnte nicht erzeugt werden." }, { status: 500 });
}
}
@@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer } from "@/payload-types";
export const dynamic = "force-dynamic";
// Deliberately identical whether or not an account exists / is already
// verified — this endpoint must never let a caller find out either of
// those things about an email address that isn't their own session.
const GENERIC_RESPONSE = {
ok: true,
message: "Falls ein Konto mit dieser E-Mail-Adresse existiert und noch nicht bestätigt ist, wurde eine neue Bestätigungs-E-Mail gesendet.",
};
async function issueAndSend(payload: Awaited<ReturnType<typeof getPayload>>, customer: Customer) {
const token = generateVerificationToken();
await payload.update({
collection: "customers",
id: customer.id,
data: {
emailVerificationTokenHash: hashVerificationToken(token),
emailVerificationExpires: verificationExpiryISO(),
},
});
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
await sendEmail(
customer.email,
verificationEmail({
name: customer.name,
verifyUrl: `${serverUrl}/auth/verify-email/${token}`,
expiresHours: Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000)),
}),
);
}
export async function POST(request: Request) {
const ip = getClientIp(request);
if (!checkRateLimit(`resend-verification:ip:${ip}`, { max: 10, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
let customer: Customer | null = null;
if (user && user.collection === "customers") {
customer = user as Customer;
} else {
let body: { email?: unknown } = {};
try {
body = await request.json();
} catch {
// no body — handled below as a missing email
}
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email) {
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
}
if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
const { docs } = await payload.find({
collection: "customers",
where: { email: { equals: email } },
limit: 1,
});
customer = (docs[0] as Customer) ?? null;
}
if (customer && !customer.emailVerified) {
if (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
await issueAndSend(payload, customer);
}
return NextResponse.json(GENERIC_RESPONSE);
} catch (error) {
console.error("resend verification failed", error);
return NextResponse.json({ error: "Die Bestätigungs-E-Mail konnte nicht gesendet werden." }, { status: 500 });
}
}
@@ -15,6 +15,9 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
if (!user.emailVerified) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
@@ -15,6 +15,8 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
// Cancelling is deliberately allowed regardless of verification status —
// only creating/confirming a booking is a gated "protected" action.
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
@@ -35,6 +35,9 @@ export async function POST(request: Request) {
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an, um einen Termin anzufragen." }, { status: 401 });
}
if (!user.emailVerified) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
const { docs } = await payload.find({
collection: "offers",
+6 -2
View File
@@ -25,6 +25,7 @@ const inter = Inter({
export const metadata: Metadata = {
title: "Seite nicht gefunden",
robots: { index: false, follow: false },
};
export default function GlobalNotFound() {
@@ -47,14 +48,17 @@ export default function GlobalNotFound() {
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.
Diese Seite wurde nicht gefunden. Vielleicht findest du deinen Weg über die
Startseite, die Angebote oder den Kontakt weiter.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Button href="/">Zur Startseite</Button>
<Button href="/angebote" variant="secondary">
Angebote ansehen
</Button>
<Button href="/kontakt" variant="secondary">
Kontakt
</Button>
</div>
</div>
<div className="relative mx-auto aspect-square w-full max-w-sm">
+32 -4
View File
@@ -1,15 +1,43 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
// Reads the CMS's SEO settings (site URL override, robots kill switch) on
// every request — must not be statically prerendered at build time.
export const dynamic = "force-dynamic";
export default async function robots(): Promise<MetadataRoute.Robots> {
const [siteUrl, indexable] = await Promise.all([getSiteUrl(), isSiteIndexable()]);
if (!indexable) {
// Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen
// sichtbar") — block everything rather than list exceptions.
return { rules: [{ userAgent: "*", disallow: "/" }] };
}
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/impressum", "/datenschutz"],
disallow: [
// Admin panel and its API.
"/admin",
"/admin/*",
"/api/*",
// Customer accounts — never public.
"/konto",
"/konto/*",
"/login",
"/registrieren",
"/auth/*",
// Private calendar-subscription feed (secret-token URLs).
"/calendar/*",
// Meeting join/call flow — session-gated, never a page worth indexing.
"/termine/*/beitreten",
"/termine/*/call",
],
},
],
sitemap: `${siteConfig.domain}/sitemap.xml`,
sitemap: `${siteUrl}/sitemap.xml`,
};
}
+73 -23
View File
@@ -1,29 +1,79 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
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",
// Reads live CMS content on every request — must not be statically
// prerendered at build time (no DB is available during `next build`; see
// AGENTS.md / the same convention used by every other CMS-backed route).
export const dynamic = "force-dynamic";
// Static, always-public routes that aren't backed by a dynamic [slug]
// collection. Deliberately excludes: /login, /registrieren, /konto*,
// /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
// /api/* — none of those are public content (see app/robots.ts, which
// mirrors this same exclusion list).
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
{ path: "", changeFrequency: "weekly", priority: 1 },
{ path: "/ueber-mich", changeFrequency: "monthly", priority: 0.7 },
{ path: "/angebote", changeFrequency: "monthly", priority: 0.9 },
{ path: "/angebote/singkreise", changeFrequency: "monthly", priority: 0.6 },
{ path: "/aktuelles", changeFrequency: "weekly", priority: 0.7 },
{ path: "/termine", changeFrequency: "weekly", priority: 0.7 },
{ path: "/termin-buchen", changeFrequency: "monthly", priority: 0.8 },
{ path: "/kontakt", changeFrequency: "yearly", priority: 0.5 },
{ path: "/impressionen", changeFrequency: "monthly", priority: 0.3 },
{ path: "/impressum", changeFrequency: "yearly", priority: 0.1 },
{ path: "/datenschutz", changeFrequency: "yearly", priority: 0.1 },
];
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,
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
if (!(await isSiteIndexable())) return [];
const siteUrl = await getSiteUrl();
const now = new Date();
const entries: MetadataRoute.Sitemap = staticRoutes.map((route) => ({
url: `${siteUrl}${route.path}`,
lastModified: now,
changeFrequency: route.changeFrequency,
priority: route.priority,
}));
// getOffers/getAllEvents/getPosts already only return published,
// publicly-readable documents (overrideAccess: false — see
// lib/payload/content.ts's header comment).
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
for (const offer of offers) {
if (!offer.slug || offer.visibility === "private") continue;
entries.push({
url: `${siteUrl}/angebote/${offer.slug}`,
lastModified: new Date(offer.updatedAt),
changeFrequency: "monthly",
priority: 0.8,
});
}
for (const event of events) {
// Private single-session bookings must never appear in the sitemap.
if (!event.slug || event.isPrivateBooking) continue;
entries.push({
url: `${siteUrl}/termine/${event.slug}`,
lastModified: new Date(event.updatedAt),
changeFrequency: "weekly",
priority: 0.6,
});
}
for (const post of posts) {
if (!post.slug) continue;
entries.push({
url: `${siteUrl}/aktuelles/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "monthly",
priority: 0.6,
});
}
return entries;
}