diff --git a/app/(frontend)/aktuelles/[slug]/page.tsx b/app/(frontend)/aktuelles/[slug]/page.tsx
index 42ebbbc..bb798e1 100644
--- a/app/(frontend)/aktuelles/[slug]/page.tsx
+++ b/app/(frontend)/aktuelles/[slug]/page.tsx
@@ -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 {
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 (
<>
+
{
- 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 (
<>
+
};
+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 {
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" && (
+
+ )}
Termin auswählen
-
+
) : (
diff --git a/app/(frontend)/angebote/page.tsx b/app/(frontend)/angebote/page.tsx
index 5f00b57..5b28a14 100644
--- a/app/(frontend)/angebote/page.tsx
+++ b/app/(frontend)/angebote/page.tsx
@@ -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 {
- 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 (
<>
+
{
+ 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();
diff --git a/app/(frontend)/auth/verify-email/[token]/page.tsx b/app/(frontend)/auth/verify-email/[token]/page.tsx
new file mode 100644
index 0000000..363631f
--- /dev/null
+++ b/app/(frontend)/auth/verify-email/[token]/page.tsx
@@ -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 (
+ <>
+
+
+
+ {verified ? (
+ <>
+
Dein Konto ist jetzt aktiviert.
+
+ Zum Konto
+
+ >
+ ) : (
+ <>
+
+ Dieser Verifizierungslink ist ungültig oder abgelaufen.
+
+
+
+
+ Zur Anmeldung
+
+
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/app/(frontend)/calendar/[tokenFile]/route.ts b/app/(frontend)/calendar/[tokenFile]/route.ts
new file mode 100644
index 0000000..4f0b9a9
--- /dev/null
+++ b/app/(frontend)/calendar/[tokenFile]/route.ts
@@ -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/.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",
+ },
+ });
+}
diff --git a/app/(frontend)/datenschutz/page.tsx b/app/(frontend)/datenschutz/page.tsx
index db185a7..79499fc 100644
--- a/app/(frontend)/datenschutz/page.tsx
+++ b/app/(frontend)/datenschutz/page.tsx
@@ -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 {
+ 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();
diff --git a/app/(frontend)/impressionen/page.tsx b/app/(frontend)/impressionen/page.tsx
index df8589f..78619e5 100644
--- a/app/(frontend)/impressionen/page.tsx
+++ b/app/(frontend)/impressionen/page.tsx
@@ -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 {
+ 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 (
<>
+
{
+ 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();
diff --git a/app/(frontend)/kontakt/page.tsx b/app/(frontend)/kontakt/page.tsx
index aadf460..8455b95 100644
--- a/app/(frontend)/kontakt/page.tsx
+++ b/app/(frontend)/kontakt/page.tsx
@@ -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 {
- 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 (
<>
+
Mein Konto
Hallo, {customer.name}
+ {!customer.emailVerified && (
+
+
Bitte bestätige zuerst deine E-Mail-Adresse.
+
+ Erst danach kannst du Termine anfragen. Prüfe dein Postfach oder fordere unten eine neue E-Mail an.
+
+
+
+ )}
{navItems.map((item) => (
diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx
index 4077020..94afb7d 100644
--- a/app/(frontend)/layout.tsx
+++ b/app/(frontend)/layout.tsx
@@ -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 (
) {
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
>
+
{
- 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 (
<>
+
{
- 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 (
<>
+
};
diff --git a/app/(frontend)/termine/[slug]/page.tsx b/app/(frontend)/termine/[slug]/page.tsx
index 68aec70..02bcadf 100644
--- a/app/(frontend)/termine/[slug]/page.tsx
+++ b/app/(frontend)/termine/[slug]/page.tsx
@@ -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 {
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 (
<>
+
{
+ 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 (
<>
+
{
- 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 (
<>
+
>, 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 });
+ }
+}
diff --git a/app/(payload)/api/booking/[id]/accept-alternative/route.ts b/app/(payload)/api/booking/[id]/accept-alternative/route.ts
index 25cf6b4..445e691 100644
--- a/app/(payload)/api/booking/[id]/accept-alternative/route.ts
+++ b/app/(payload)/api/booking/[id]/accept-alternative/route.ts
@@ -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)) {
diff --git a/app/(payload)/api/booking/[id]/cancel/route.ts b/app/(payload)/api/booking/[id]/cancel/route.ts
index 911e6b0..8cd7f67 100644
--- a/app/(payload)/api/booking/[id]/cancel/route.ts
+++ b/app/(payload)/api/booking/[id]/cancel/route.ts
@@ -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)) {
diff --git a/app/(payload)/api/booking/request/route.ts b/app/(payload)/api/booking/request/route.ts
index 9073486..031a6c7 100644
--- a/app/(payload)/api/booking/request/route.ts
+++ b/app/(payload)/api/booking/request/route.ts
@@ -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",
diff --git a/app/global-not-found.tsx b/app/global-not-found.tsx
index bba7da6..a77363f 100644
--- a/app/global-not-found.tsx
+++ b/app/global-not-found.tsx
@@ -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
- 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.
Zur Startseite
Angebote ansehen
+
+ Kontakt
+
diff --git a/app/robots.ts b/app/robots.ts
index ae5072b..2e905c4 100644
--- a/app/robots.ts
+++ b/app/robots.ts
@@ -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
{
+ 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`,
};
}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 0c7ad1d..086b4bb 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -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 {
+ 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;
}
diff --git a/collections/Customers.ts b/collections/Customers.ts
index 91b9451..dbccd71 100644
--- a/collections/Customers.ts
+++ b/collections/Customers.ts
@@ -1,4 +1,8 @@
-import type { CollectionConfig } from "payload";
+import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
+import { isAdminFieldLevel } from "@/access";
+import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
+import { verificationEmail } from "@/lib/email/authTemplates";
+import { sendEmail } from "@/lib/email/sendBookingEmails";
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
const user = req.user;
@@ -8,6 +12,41 @@ const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unkn
return false;
};
+// Runs on every new customer account: issues a random, single-use,
+// time-limited email-verification token. Only its hash is ever persisted
+// (see the emailVerificationTokenHash field below) — the plaintext exists
+// only in req.context for the afterChange hook below to email out, and is
+// never written to the database or logged.
+const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
+ if (operation !== "create") return data;
+ const token = generateVerificationToken();
+ data.emailVerified = false;
+ data.emailVerificationTokenHash = hashVerificationToken(token);
+ data.emailVerificationExpires = verificationExpiryISO();
+ req.context.pendingVerificationToken = token;
+ return data;
+};
+
+const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
+ if (operation !== "create") return doc;
+ const token = req.context.pendingVerificationToken;
+ if (typeof token !== "string") return doc;
+ try {
+ const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
+ await sendEmail(
+ doc.email,
+ verificationEmail({
+ name: doc.name,
+ verifyUrl: `${serverUrl}/auth/verify-email/${token}`,
+ expiresHours: Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000)),
+ }),
+ );
+ } catch (err) {
+ req.payload.logger.error({ err, msg: "customers sendVerificationEmail failed" });
+ }
+ return doc;
+};
+
export const Customers: CollectionConfig = {
slug: "customers",
labels: {
@@ -32,6 +71,10 @@ export const Customers: CollectionConfig = {
update: isSelfOrAdmin,
delete: ({ req }) => req.user?.collection === "users",
},
+ hooks: {
+ beforeChange: [issueVerificationToken],
+ afterChange: [sendVerificationEmail],
+ },
fields: [
{
name: "name",
@@ -48,6 +91,35 @@ export const Customers: CollectionConfig = {
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
},
},
+ {
+ name: "emailVerified",
+ type: "checkbox",
+ label: "E-Mail bestätigt",
+ defaultValue: false,
+ // "create" AND "update" must both be locked down — otherwise a
+ // customer could simply include emailVerified: true in their own
+ // registration or profile request and skip verification entirely.
+ access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
+ admin: {
+ position: "sidebar",
+ description: "Wird automatisch gesetzt, sobald der Bestätigungslink aus der E-Mail angeklickt wird. Kann hier bei Bedarf manuell gesetzt werden.",
+ },
+ },
+ {
+ name: "emailVerificationTokenHash",
+ type: "text",
+ // Never exposed or settable via any API — only the two hooks above
+ // (running through the trusted Local API) ever touch this field.
+ access: { create: () => false, read: () => false, update: () => false },
+ admin: { hidden: true },
+ index: true,
+ },
+ {
+ name: "emailVerificationExpires",
+ type: "date",
+ access: { create: () => false, read: () => false, update: () => false },
+ admin: { hidden: true },
+ },
// "email" and "password" are added automatically by the auth config.
],
};
diff --git a/collections/Events.ts b/collections/Events.ts
index 08b11a8..3389b82 100644
--- a/collections/Events.ts
+++ b/collections/Events.ts
@@ -1,6 +1,7 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
+import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password";
export const EVENT_CATEGORIES = [
@@ -243,5 +244,6 @@ export const Events: CollectionConfig = {
label: "Zugehörige Buchungsanfrage",
admin: { position: "sidebar", readOnly: true },
},
+ seoFields(),
],
};
diff --git a/collections/Offers.ts b/collections/Offers.ts
index df9d517..66d2b96 100644
--- a/collections/Offers.ts
+++ b/collections/Offers.ts
@@ -1,6 +1,7 @@
import type { CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
+import { seoFields } from "@/fields/seo";
// Groups the 7 individual offers for the mega menu and the /angebote
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
@@ -123,5 +124,6 @@ export const Offers: CollectionConfig = {
},
],
},
+ seoFields(),
],
};
diff --git a/collections/Posts.ts b/collections/Posts.ts
index ffc81f6..93629ac 100644
--- a/collections/Posts.ts
+++ b/collections/Posts.ts
@@ -1,6 +1,7 @@
import type { CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
+import { seoFields } from "@/fields/seo";
export const Posts: CollectionConfig = {
slug: "posts",
@@ -64,5 +65,13 @@ export const Posts: CollectionConfig = {
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
},
},
+ {
+ name: "author",
+ type: "text",
+ label: "Autor:in",
+ defaultValue: "Anouma",
+ admin: { position: "sidebar" },
+ },
+ seoFields(),
],
};
diff --git a/collections/Redirects.ts b/collections/Redirects.ts
new file mode 100644
index 0000000..a40d272
--- /dev/null
+++ b/collections/Redirects.ts
@@ -0,0 +1,76 @@
+import type { CollectionConfig } from "payload";
+import { isAdmin } from "@/access";
+
+function normalizePath(value: string): string {
+ const trimmed = value.trim();
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
+ return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
+}
+
+/**
+ * Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately
+ * scoped to the three dynamic [slug] detail routes (Angebote/Termine/
+ * Aktuelles) — that's the only place slugs actually live and can change, so
+ * looking this up only there avoids adding a database lookup to every
+ * single request the way a catch-all middleware would.
+ */
+export const Redirects: CollectionConfig = {
+ slug: "redirects",
+ labels: {
+ singular: "Weiterleitung",
+ plural: "Weiterleitungen",
+ },
+ admin: {
+ useAsTitle: "fromPath",
+ defaultColumns: ["fromPath", "toPath", "type", "enabled"],
+ group: "SEO",
+ description: "Leitet eine alte URL (z. B. nach einer Slug-Änderung bei Angeboten, Terminen oder Aktuelles) dauerhaft auf eine neue weiter.",
+ },
+ access: {
+ read: () => true,
+ create: isAdmin,
+ update: isAdmin,
+ delete: isAdmin,
+ },
+ fields: [
+ {
+ name: "fromPath",
+ type: "text",
+ label: "Alte URL (Pfad)",
+ required: true,
+ unique: true,
+ index: true,
+ admin: { description: "Nur der Pfad, z. B. /angebote/doula (ohne Domain)." },
+ hooks: {
+ beforeValidate: [({ value }) => (typeof value === "string" && value ? normalizePath(value) : value)],
+ },
+ },
+ {
+ name: "toPath",
+ type: "text",
+ label: "Neue URL (Pfad)",
+ required: true,
+ admin: { description: "Ziel-Pfad, z. B. /angebote/doula-begleitung." },
+ hooks: {
+ beforeValidate: [({ value }) => (typeof value === "string" && value ? normalizePath(value) : value)],
+ },
+ },
+ {
+ name: "type",
+ type: "select",
+ label: "Art der Weiterleitung",
+ required: true,
+ defaultValue: "permanent",
+ options: [
+ { label: "301 – Dauerhaft", value: "permanent" },
+ { label: "302 – Vorübergehend", value: "temporary" },
+ ],
+ },
+ {
+ name: "enabled",
+ type: "checkbox",
+ label: "Aktiv",
+ defaultValue: true,
+ },
+ ],
+};
diff --git a/collections/Users.ts b/collections/Users.ts
index 19a64f5..b1c846f 100644
--- a/collections/Users.ts
+++ b/collections/Users.ts
@@ -45,6 +45,17 @@ export const Users: CollectionConfig = {
update: isAdminFieldLevel,
},
},
+ {
+ name: "calendarFeedTokenHash",
+ type: "text",
+ // Only ever set by /api/admin/calendar-feed/regenerate (see
+ // lib/calendar/feedToken.ts) — never exposed or directly settable via
+ // any API, so the plaintext token is never retrievable again after
+ // it's shown once at generation time.
+ access: { create: () => false, read: () => false, update: () => false },
+ admin: { hidden: true },
+ index: true,
+ },
// "email" and "password" are added automatically by `auth: true`-style config.
],
};
diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx
index fadf5f7..b0a4621 100644
--- a/components/PageHeader.tsx
+++ b/components/PageHeader.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs";
import { OrganicBlob } from "@/components/OrganicBlob";
+import { JsonLd, breadcrumbJsonLd } from "@/lib/seo/jsonld";
+import { getSiteUrl } from "@/lib/seo/config";
type PageHeaderProps = {
eyebrow?: string;
@@ -9,11 +11,24 @@ type PageHeaderProps = {
crumbs?: Crumb[];
};
-export function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
+/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` — every page that already passes `crumbs` gets structured breadcrumb data for free. */
+export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
+ const breadcrumbData = crumbs?.length
+ ? await (async () => {
+ const siteUrl = await getSiteUrl();
+ const items = [
+ { name: "Startseite", url: siteUrl },
+ ...crumbs.map((crumb) => ({ name: crumb.title, url: crumb.href ? `${siteUrl}${crumb.href}` : undefined })),
+ ];
+ return breadcrumbJsonLd(items);
+ })()
+ : null;
+
return (
+ {breadcrumbData &&
}
{crumbs &&
}
{eyebrow && (
diff --git a/components/admin/AdminCalendarView.tsx b/components/admin/AdminCalendarView.tsx
index c88640d..a53f774 100644
--- a/components/admin/AdminCalendarView.tsx
+++ b/components/admin/AdminCalendarView.tsx
@@ -1,5 +1,6 @@
import type { AdminViewServerProps } from "payload";
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
+import { CalendarFeedPanel } from "./CalendarFeedPanel";
import styles from "./AdminCalendarView.module.css";
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -58,6 +59,12 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
rangeEnd.setDate(rangeEnd.getDate() + 41); // 6 full weeks
const items = await getAdminCalendarItems(req.payload, { from: rangeStart, to: rangeEnd });
+
+ // Local API (overrideAccess: true by default) so this can see
+ // calendarFeedTokenHash despite its field-level access being locked to
+ // "nobody" for every real API path — see collections/Users.ts.
+ const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null;
+ const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
const itemsByDay = new Map();
for (const item of items) {
const key = dateKey(new Date(item.date));
@@ -134,6 +141,8 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
))}
+
+
diff --git a/components/admin/CalendarFeedPanel.module.css b/components/admin/CalendarFeedPanel.module.css
new file mode 100644
index 0000000..fdb2096
--- /dev/null
+++ b/components/admin/CalendarFeedPanel.module.css
@@ -0,0 +1,122 @@
+.panel {
+ margin-top: 28px;
+ border: 1px solid #e6e6e6;
+ border-radius: 12px;
+ padding: 20px 22px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+.heading {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 4px;
+ color: #1f1f1f;
+}
+
+.hint {
+ font-size: 13px;
+ color: #666;
+ margin: 0 0 14px;
+ line-height: 1.5;
+}
+
+.status {
+ font-size: 13px;
+ color: #555;
+ margin: 0 0 14px;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ border: none;
+ border-radius: 999px;
+ background: #8b616d;
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 9px 18px;
+ cursor: pointer;
+}
+.button:hover {
+ background: #66505f;
+}
+.button:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.secondaryButton {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 9px 18px;
+ cursor: pointer;
+ background: transparent;
+ color: #8b616d;
+ border: 1px solid #8b616d;
+}
+.secondaryButton:hover {
+ background: #f7f0f2;
+}
+.secondaryButton:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.result {
+ margin-top: 16px;
+ border-radius: 10px;
+ background: #f7f4f1;
+ padding: 14px 16px;
+}
+
+.resultWarning {
+ font-size: 12px;
+ font-weight: 600;
+ color: #7a2020;
+ margin: 0 0 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.urlRow {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.urlInput {
+ flex: 1;
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+ font-size: 12px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ border: 1px solid #dcdcdc;
+ background: #fff;
+ color: #333;
+}
+
+.copied {
+ font-size: 12px;
+ color: #3a4f30;
+ margin: 8px 0 0;
+}
+
+.error {
+ font-size: 13px;
+ color: #7a2020;
+ margin-top: 10px;
+}
+
+.instructions {
+ margin-top: 18px;
+ font-size: 12px;
+ color: #777;
+ line-height: 1.6;
+}
+.instructions strong {
+ color: #444;
+}
diff --git a/components/admin/CalendarFeedPanel.tsx b/components/admin/CalendarFeedPanel.tsx
new file mode 100644
index 0000000..7a350d3
--- /dev/null
+++ b/components/admin/CalendarFeedPanel.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useState } from "react";
+import styles from "./CalendarFeedPanel.module.css";
+
+export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured: boolean }) {
+ const [configured, setConfigured] = useState(initiallyConfigured);
+ const [loading, setLoading] = useState(false);
+ const [url, setUrl] = useState
(null);
+ const [copied, setCopied] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function generate() {
+ setLoading(true);
+ setError(null);
+ setCopied(false);
+ try {
+ const res = await fetch("/api/admin/calendar-feed/regenerate", { method: "POST", credentials: "include" });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(data.error || "Der Kalender-Link konnte nicht erzeugt werden.");
+ return;
+ }
+ setUrl(data.url);
+ setConfigured(true);
+ } catch {
+ setError("Verbindung fehlgeschlagen.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function copy() {
+ if (!url) return;
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ } catch {
+ // Clipboard API can be unavailable (e.g. insecure context) — the
+ // input is still selectable/copyable by hand in that case.
+ }
+ }
+
+ return (
+
+
Kalender synchronisieren
+
+ Abonniere deinen persönlichen Kalender in Apple Kalender, Google Kalender, Outlook, Thunderbird oder einer
+ anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken — teile ihn nicht.
+
+
+ {!url && (
+
+ {configured ? "Ein Kalender-Link ist eingerichtet." : "Es ist noch kein Kalender-Link eingerichtet."}
+
+ )}
+
+ {!url && (
+
+ {loading ? "Wird erzeugt …" : configured ? "Kalender-Link neu generieren" : "Kalender-Link generieren"}
+
+ )}
+
+ {error &&
{error}
}
+
+ {url && (
+
+
Wird nur jetzt einmal angezeigt — bitte gleich kopieren.
+
+ e.currentTarget.select()} />
+
+ Kopieren
+
+
+ {copied &&
In die Zwischenablage kopiert.
}
+
+ {loading ? "Wird erzeugt …" : "Kalender-Link neu generieren"}
+
+
+ )}
+
+
+ Apple Kalender: Ablage → Neues Kalenderabonnement → Link einfügen.
+
+ Google Kalender: Weitere Kalender „+“ → Per URL → Link einfügen.
+
+ Outlook: Kalender hinzufügen → Aus dem Internet abonnieren → Link einfügen.
+
+ Thunderbird: Kalender → Neuer Kalender → Im Netzwerk → Link einfügen.
+
+
+ );
+}
diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx
index a4d67da..74d2c08 100644
--- a/components/auth/LoginForm.tsx
+++ b/components/auth/LoginForm.tsx
@@ -2,6 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
+import { ResendVerificationButton } from "./ResendVerificationButton";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
@@ -67,6 +68,7 @@ export function LoginForm() {
>
{loading ? "Wird geprüft …" : "Anmelden"}
+
);
}
diff --git a/components/auth/ResendVerificationButton.tsx b/components/auth/ResendVerificationButton.tsx
new file mode 100644
index 0000000..0dbcddc
--- /dev/null
+++ b/components/auth/ResendVerificationButton.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+
+const fieldClass =
+ "w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-2.5 text-sm text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
+
+async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
+ try {
+ const res = await fetch("/api/auth/resend-verification", {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(email ? { email } : {}),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) return { ok: false, error: data.error || "Die Bestätigungs-E-Mail konnte nicht gesendet werden." };
+ return { ok: true };
+ } catch {
+ return { ok: false, error: "Verbindung fehlgeschlagen." };
+ }
+}
+
+const GENERIC_SENT_MESSAGE =
+ "Falls das Konto noch nicht bestätigt ist, wurde eine neue Bestätigungs-E-Mail gesendet.";
+
+/**
+ * variant "session": a single button that resends for the currently logged-in
+ * customer (used in /konto and the booking widget, where a session already
+ * exists).
+ *
+ * variant "email-prompt": a toggle link that reveals an email field first
+ * (used on /login, where the visitor may not be authenticated yet).
+ */
+export function ResendVerificationButton({
+ variant,
+ triggerLabel = "E-Mail noch nicht bestätigt?",
+ className,
+}: {
+ variant: "session" | "email-prompt";
+ triggerLabel?: string;
+ className?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
+ const [error, setError] = useState(null);
+
+ async function resendForSession() {
+ setStatus("sending");
+ const result = await callResend();
+ if (result.ok) {
+ setStatus("sent");
+ } else {
+ setStatus("error");
+ setError(result.error ?? null);
+ }
+ }
+
+ async function handleEmailSubmit(e: FormEvent) {
+ e.preventDefault();
+ setStatus("sending");
+ setError(null);
+ const email = String(new FormData(e.currentTarget).get("email") ?? "");
+ const result = await callResend(email);
+ if (result.ok) {
+ setStatus("sent");
+ } else {
+ setStatus("error");
+ setError(result.error ?? null);
+ }
+ }
+
+ if (variant === "session") {
+ return (
+
+ {status === "sent" ? (
+
{GENERIC_SENT_MESSAGE}
+ ) : (
+
+ {status === "sending" ? "Wird gesendet …" : "Bestätigungs-E-Mail erneut senden"}
+
+ )}
+ {status === "error" && error && (
+
+ {error}
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {!open ? (
+
setOpen(true)}
+ className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
+ >
+ {triggerLabel}
+
+ ) : status === "sent" ? (
+
{GENERIC_SENT_MESSAGE}
+ ) : (
+
+ )}
+ {status === "error" && error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/components/booking/BookingWidget.tsx b/components/booking/BookingWidget.tsx
index 6be8a59..ed7e46a 100644
--- a/components/booking/BookingWidget.tsx
+++ b/components/booking/BookingWidget.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
+import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
type SlotsResponse = { durationMinutes: number; slots: Record };
@@ -16,7 +17,15 @@ function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
}
-export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; isLoggedIn: boolean }) {
+export function BookingWidget({
+ offerSlug,
+ isLoggedIn,
+ isVerified,
+}: {
+ offerSlug: string;
+ isLoggedIn: boolean;
+ isVerified: boolean;
+}) {
const [slotsByDay, setSlotsByDay] = useState({});
const [loading, setLoading] = useState(true);
const [selectedDay, setSelectedDay] = useState(null);
@@ -76,6 +85,16 @@ export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; is
);
}
+ if (!isVerified) {
+ return (
+
+
Bitte bestätige zuerst deine E-Mail-Adresse.
+
Erst danach kannst du einen Termin anfragen.
+
+
+ );
+ }
+
const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" }));
const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : [];
diff --git a/fields/seo.ts b/fields/seo.ts
new file mode 100644
index 0000000..1f92b8c
--- /dev/null
+++ b/fields/seo.ts
@@ -0,0 +1,53 @@
+import type { Field } from "payload";
+
+/**
+ * Reusable, always-optional SEO field group — added to every public-facing
+ * collection/global. Never required: every consumer falls back to a
+ * sensible, automatically derived value when a field is left empty (see
+ * lib/seo/resolve.ts), per the "SEO fields are optional with fallbacks"
+ * requirement.
+ */
+export function seoFields(): Field {
+ return {
+ type: "collapsible",
+ label: "SEO",
+ admin: {
+ description: "Optional — wird automatisch aus dem Inhalt abgeleitet, wenn hier nichts eingetragen ist.",
+ },
+ fields: [
+ {
+ name: "seo",
+ type: "group",
+ label: "",
+ fields: [
+ {
+ name: "title",
+ type: "text",
+ label: "SEO Title",
+ maxLength: 70,
+ admin: { description: "Falls leer: automatisch aus dem Seitentitel erzeugt." },
+ },
+ {
+ name: "description",
+ type: "textarea",
+ label: "Meta Description",
+ maxLength: 300,
+ admin: { description: "Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen)." },
+ },
+ {
+ name: "keywords",
+ type: "text",
+ label: "SEO Keywords (optional, kommagetrennt)",
+ },
+ {
+ name: "ogImage",
+ type: "upload",
+ relationTo: "media",
+ label: "Open Graph Bild",
+ admin: { description: "Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen." },
+ },
+ ],
+ },
+ ],
+ };
+}
diff --git a/globals/About.ts b/globals/About.ts
index 4c3532b..10f3b65 100644
--- a/globals/About.ts
+++ b/globals/About.ts
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
+import { seoFields } from "@/fields/seo";
export const About: GlobalConfig = {
slug: "about",
@@ -26,5 +27,6 @@ export const About: GlobalConfig = {
{ name: "closingParagraph", type: "textarea", label: "Abschlusstext" },
],
},
+ seoFields(),
],
};
diff --git a/globals/AktuellesIntro.ts b/globals/AktuellesIntro.ts
index 193e7bb..af1bfa4 100644
--- a/globals/AktuellesIntro.ts
+++ b/globals/AktuellesIntro.ts
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
+import { seoFields } from "@/fields/seo";
export const AktuellesIntro: GlobalConfig = {
slug: "aktuelles-intro",
@@ -16,5 +17,6 @@ export const AktuellesIntro: GlobalConfig = {
{ name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" },
+ seoFields(),
],
};
diff --git a/globals/AngeboteIntro.ts b/globals/AngeboteIntro.ts
index 15bcde2..3909272 100644
--- a/globals/AngeboteIntro.ts
+++ b/globals/AngeboteIntro.ts
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
+import { seoFields } from "@/fields/seo";
export const AngeboteIntro: GlobalConfig = {
slug: "angebote-intro",
@@ -16,5 +17,6 @@ export const AngeboteIntro: GlobalConfig = {
{ name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" },
+ seoFields(),
],
};
diff --git a/globals/Booking.ts b/globals/Booking.ts
index 9cfbc1c..8c50803 100644
--- a/globals/Booking.ts
+++ b/globals/Booking.ts
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
+import { seoFields } from "@/fields/seo";
export const Booking: GlobalConfig = {
slug: "booking",
@@ -28,5 +29,6 @@ export const Booking: GlobalConfig = {
],
},
{ name: "formNote", type: "textarea", label: "Hinweis unter dem Formular" },
+ seoFields(),
],
};
diff --git a/globals/BookingSettings.ts b/globals/BookingSettings.ts
index 5ddffa6..7ce93be 100644
--- a/globals/BookingSettings.ts
+++ b/globals/BookingSettings.ts
@@ -15,7 +15,14 @@ export const BookingSettings: GlobalConfig = {
fields: [
{ name: "locationName", type: "text", label: "Name des Ortes", defaultValue: "Anouma" },
{ name: "street", type: "text", label: "Straße und Hausnummer" },
- { name: "postalCode", type: "text", label: "PLZ" },
- { name: "city", type: "text", label: "Ort" },
+ {
+ type: "row",
+ fields: [
+ { name: "postalCode", type: "text", label: "PLZ", admin: { width: "34%" } },
+ { name: "city", type: "text", label: "Ort", admin: { width: "33%" } },
+ { name: "region", type: "text", label: "Region/Bundesland (optional)", admin: { width: "33%" } },
+ ],
+ },
+ { name: "country", type: "text", label: "Land (optional)", admin: { description: "Für strukturierte Daten, z. B. „Deutschland“." } },
],
};
diff --git a/globals/Contact.ts b/globals/Contact.ts
index f0dfab0..1fbb0fe 100644
--- a/globals/Contact.ts
+++ b/globals/Contact.ts
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
+import { seoFields } from "@/fields/seo";
export const Contact: GlobalConfig = {
slug: "contact",
@@ -24,5 +25,6 @@ export const Contact: GlobalConfig = {
{ name: "region", type: "text", label: "Region / Ort", admin: { width: "33%" } },
],
},
+ seoFields(),
],
};
diff --git a/globals/Home.ts b/globals/Home.ts
index 1525966..985145d 100644
--- a/globals/Home.ts
+++ b/globals/Home.ts
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
+import { seoFields } from "@/fields/seo";
export const Home: GlobalConfig = {
slug: "home",
@@ -66,5 +67,6 @@ export const Home: GlobalConfig = {
},
],
},
+ seoFields(),
],
};
diff --git a/globals/SEOSettings.ts b/globals/SEOSettings.ts
new file mode 100644
index 0000000..e6db6a7
--- /dev/null
+++ b/globals/SEOSettings.ts
@@ -0,0 +1,146 @@
+import type { GlobalConfig } from "payload";
+import { isAdmin } from "@/access";
+
+const WEEKDAYS = [
+ { label: "Montag", value: "Monday" },
+ { label: "Dienstag", value: "Tuesday" },
+ { label: "Mittwoch", value: "Wednesday" },
+ { label: "Donnerstag", value: "Thursday" },
+ { label: "Freitag", value: "Friday" },
+ { label: "Samstag", value: "Saturday" },
+ { label: "Sonntag", value: "Sunday" },
+] as const;
+
+/**
+ * Central SEO settings, edited once by Anna, applied automatically across
+ * the whole website (see lib/seo/*). Everything here is optional — nothing
+ * is invented if a field is left blank (see lib/seo/jsonld.ts, which only
+ * emits LocalBusiness structured data once real address data exists).
+ */
+export const SEOSettings: GlobalConfig = {
+ slug: "seo-settings",
+ label: "SEO-Einstellungen",
+ admin: {
+ description: "Globale SEO-Vorgaben für die gesamte Website — greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.",
+ group: "SEO",
+ },
+ access: {
+ read: () => true,
+ update: isAdmin,
+ },
+ fields: [
+ {
+ type: "tabs",
+ tabs: [
+ {
+ label: "Allgemein",
+ fields: [
+ {
+ name: "siteUrl",
+ type: "text",
+ label: "Website-URL",
+ admin: {
+ description: "Nur bei Bedarf setzen (z. B. https://anouma.org) — überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.",
+ },
+ validate: (value: string | null | undefined) => {
+ if (!value) return true;
+ return /^https?:\/\/.+/.test(value) || "Bitte eine vollständige URL angeben (z. B. https://anouma.org).";
+ },
+ },
+ {
+ name: "defaultDescription",
+ type: "textarea",
+ label: "Standard-Beschreibung",
+ maxLength: 300,
+ admin: { description: "Wird verwendet, wenn eine Seite keine eigene Meta-Beschreibung hat." },
+ },
+ {
+ name: "defaultOgImage",
+ type: "upload",
+ relationTo: "media",
+ label: "Standard Social-Media-Bild",
+ admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat." },
+ },
+ {
+ name: "robotsIndexable",
+ type: "checkbox",
+ label: "Website für Suchmaschinen sichtbar",
+ defaultValue: true,
+ admin: {
+ description: "Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto — die sind ohnehin immer gesperrt.",
+ },
+ },
+ ],
+ },
+ {
+ label: "Lokale Informationen",
+ admin: { description: "Werden nur verwendet, wenn tatsächlich ausgefüllt — es werden keine Angaben erfunden." },
+ fields: [
+ {
+ name: "businessName",
+ type: "text",
+ label: "Geschäfts-/Markenname",
+ defaultValue: "Anouma",
+ },
+ {
+ name: "businessDescription",
+ type: "textarea",
+ label: "Kurzbeschreibung",
+ admin: { description: "Kurzer, sachlicher Beschreibungstext für strukturierte Daten (nicht der Website-Text)." },
+ },
+ {
+ type: "row",
+ fields: [
+ { name: "latitude", type: "number", label: "Breitengrad (optional)", admin: { width: "50%" } },
+ { name: "longitude", type: "number", label: "Längengrad (optional)", admin: { width: "50%" } },
+ ],
+ },
+ {
+ name: "openingHours",
+ type: "array",
+ label: "Öffnungs-/Erreichbarkeitszeiten",
+ admin: { description: "Optional — nur für tatsächlich feste Zeiten (z. B. Sprechzeiten)." },
+ fields: [
+ {
+ type: "row",
+ fields: [
+ {
+ name: "days",
+ type: "select",
+ label: "Tage",
+ hasMany: true,
+ required: true,
+ options: [...WEEKDAYS],
+ admin: { width: "50%" },
+ },
+ { name: "opens", type: "text", label: "Von (z. B. 09:00)", required: true, admin: { width: "25%" } },
+ { name: "closes", type: "text", label: "Bis (z. B. 17:00)", required: true, admin: { width: "25%" } },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Social Media",
+ fields: [
+ {
+ name: "socialLinks",
+ type: "array",
+ label: "Profile",
+ fields: [
+ {
+ type: "row",
+ fields: [
+ { name: "platform", type: "text", label: "Plattform (z. B. Instagram)", required: true, admin: { width: "35%" } },
+ { name: "url", type: "text", label: "Link", required: true, admin: { width: "65%" } },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
diff --git a/lib/auth/rateLimit.ts b/lib/auth/rateLimit.ts
new file mode 100644
index 0000000..3dc9dcd
--- /dev/null
+++ b/lib/auth/rateLimit.ts
@@ -0,0 +1,38 @@
+/**
+ * Minimal in-memory fixed-window rate limiter for a single Node process
+ * (this app always runs as one process — see server.ts). Good enough to
+ * blunt abuse of the email-verification resend endpoint without adding an
+ * external store; resets on deploy/restart, which is an acceptable
+ * trade-off for this use case.
+ */
+const buckets = new Map();
+
+// Opportunistic cleanup so long-running processes don't accumulate an
+// unbounded number of stale keys (one per distinct IP/email ever seen).
+const MAX_TRACKED_KEYS = 5000;
+
+function sweepExpired(now: number) {
+ for (const [key, bucket] of buckets) {
+ if (bucket.resetAt < now) buckets.delete(key);
+ }
+}
+
+export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean {
+ const now = Date.now();
+ if (buckets.size > MAX_TRACKED_KEYS) sweepExpired(now);
+
+ const bucket = buckets.get(key);
+ if (!bucket || bucket.resetAt < now) {
+ buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
+ return true;
+ }
+ if (bucket.count >= opts.max) return false;
+ bucket.count += 1;
+ return true;
+}
+
+export function getClientIp(request: Request): string {
+ const forwarded = request.headers.get("x-forwarded-for");
+ if (forwarded) return forwarded.split(",")[0]!.trim();
+ return "unknown";
+}
diff --git a/lib/auth/verification.ts b/lib/auth/verification.ts
new file mode 100644
index 0000000..9292782
--- /dev/null
+++ b/lib/auth/verification.ts
@@ -0,0 +1,22 @@
+import { createHash, randomBytes } from "node:crypto";
+
+/** How long a freshly issued email-verification link stays valid. */
+export const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
+
+/** 256 bits of randomness, hex-encoded — not guessable, never derived from user data. */
+export function generateVerificationToken(): string {
+ return randomBytes(32).toString("hex");
+}
+
+/**
+ * Only this hash is ever persisted (see collections/Customers.ts) — the
+ * plaintext token exists only in the URL sent by email and briefly in
+ * memory while that email is being sent.
+ */
+export function hashVerificationToken(token: string): string {
+ return createHash("sha256").update(token).digest("hex");
+}
+
+export function verificationExpiryISO(): string {
+ return new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS).toISOString();
+}
diff --git a/lib/auth/verifyEmailToken.ts b/lib/auth/verifyEmailToken.ts
new file mode 100644
index 0000000..084dea7
--- /dev/null
+++ b/lib/auth/verifyEmailToken.ts
@@ -0,0 +1,38 @@
+import { getCMS } from "@/lib/payload/getPayload";
+import { hashVerificationToken } from "@/lib/auth/verification";
+
+const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
+
+/**
+ * Verifies a token from a /auth/verify-email/[token] link: looks it up by
+ * hash (the plaintext is never stored), checks it hasn't expired, then
+ * marks the account verified and immediately clears the hash + expiry so
+ * the same link can never be used a second time.
+ */
+export async function verifyEmailToken(token: string): Promise {
+ if (!TOKEN_SHAPE.test(token)) return false;
+
+ const payload = await getCMS();
+ const hash = hashVerificationToken(token);
+
+ const { docs } = await payload.find({
+ collection: "customers",
+ where: { emailVerificationTokenHash: { equals: hash } },
+ limit: 1,
+ });
+ const customer = docs[0];
+ if (!customer || !customer.emailVerificationExpires) return false;
+ if (new Date(customer.emailVerificationExpires).getTime() < Date.now()) return false;
+
+ await payload.update({
+ collection: "customers",
+ id: customer.id,
+ data: {
+ emailVerified: true,
+ emailVerificationTokenHash: null,
+ emailVerificationExpires: null,
+ },
+ });
+
+ return true;
+}
diff --git a/lib/calendar/adminFeed.ts b/lib/calendar/adminFeed.ts
new file mode 100644
index 0000000..941a658
--- /dev/null
+++ b/lib/calendar/adminFeed.ts
@@ -0,0 +1,109 @@
+import type { Payload } from "payload";
+import { EVENT_CATEGORIES } from "@/collections/Events";
+import type { ICSEvent } from "./ics";
+import type { BookingRequest, Customer, Event, Offer } from "@/payload-types";
+
+// A rolling window rather than "everything ever": calendar apps refetch a
+// subscribed feed periodically on their own, so this just needs to cover a
+// sensible range each time, not paginate an entire history.
+const WINDOW_PAST_MS = 30 * 24 * 60 * 60 * 1000;
+const WINDOW_FUTURE_MS = 400 * 24 * 60 * 60 * 1000;
+
+/**
+ * Builds the calendar entries for Anna's personal ICS feed: confirmed
+ * bookings (never pending ones) plus standalone public events — onsite and
+ * online alike. Deliberately never includes the meeting password anywhere
+ * in the output, only a link to the protected join page.
+ *
+ * Local API is used with the default overrideAccess: true because this is
+ * only ever reached after the caller already verified the feed's secret
+ * token (see app/(frontend)/calendar/[tokenFile]/route.ts) — equivalent
+ * trust to the existing admin calendar view.
+ */
+export async function getAdminFeedICSEvents(payload: Payload): Promise {
+ const from = new Date(Date.now() - WINDOW_PAST_MS);
+ const to = new Date(Date.now() + WINDOW_FUTURE_MS);
+ const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
+ const domainHost = new URL(serverUrl).host;
+
+ const [{ docs: bookings }, { docs: events }, settings] = await Promise.all([
+ payload.find({
+ collection: "booking-requests",
+ where: {
+ and: [
+ { status: { equals: "confirmed" } },
+ { date: { greater_than_equal: from.toISOString() } },
+ { date: { less_than_equal: to.toISOString() } },
+ ],
+ },
+ depth: 2,
+ limit: 1000,
+ }),
+ payload.find({
+ collection: "events",
+ where: {
+ and: [
+ { date: { greater_than_equal: from.toISOString() } },
+ { date: { less_than_equal: to.toISOString() } },
+ { isPrivateBooking: { not_equals: true } },
+ ],
+ },
+ depth: 0,
+ limit: 1000,
+ }),
+ payload.findGlobal({ slug: "booking-settings" }),
+ ]);
+
+ const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean);
+ const fullAddress = addressParts.join(", ");
+
+ const bookingEvents: ICSEvent[] = (bookings as BookingRequest[]).map((b) => {
+ const offer = typeof b.offer === "object" ? (b.offer as Offer) : null;
+ const customer = typeof b.user === "object" ? (b.user as Customer) : null;
+ const isOnline = b.appointmentType === "online";
+
+ const description = [
+ customer?.name ? `Mit: ${customer.name}` : null,
+ isOnline ? "Online-Termin" : "Vor-Ort-Termin",
+ b.userMessage ? `Nachricht: ${b.userMessage}` : null,
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ let url: string | undefined;
+ if (isOnline && b.linkedEvent && typeof b.linkedEvent === "object") {
+ url = `${serverUrl}/termine/${(b.linkedEvent as Event).slug}/beitreten`;
+ } else if (!isOnline && fullAddress) {
+ url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
+ }
+
+ return {
+ uid: `booking-${b.id}@${domainHost}`,
+ start: new Date(b.startTime),
+ end: new Date(b.endTime),
+ summary: offer?.title ?? "Termin",
+ description: description || undefined,
+ // Vor Ort: vollständige Adresse. Online: literal "Online" — never the
+ // meeting password, which is intentionally not part of this feed.
+ location: isOnline ? "Online" : fullAddress || undefined,
+ url,
+ };
+ });
+
+ const eventEvents: ICSEvent[] = (events as Event[]).map((e) => {
+ const isOnline = Boolean(e.isOnline);
+ const start = new Date(e.startTime || e.date);
+ const end = new Date(e.endTime || e.startTime || e.date);
+ return {
+ uid: `event-${e.id}@${domainHost}`,
+ start,
+ end,
+ summary: e.title,
+ description: EVENT_CATEGORIES.find((c) => c.value === e.category)?.label,
+ location: isOnline ? "Online" : e.location || fullAddress || undefined,
+ url: e.slug ? `${serverUrl}/termine/${e.slug}` : undefined,
+ };
+ });
+
+ return [...bookingEvents, ...eventEvents];
+}
diff --git a/lib/calendar/feedToken.ts b/lib/calendar/feedToken.ts
new file mode 100644
index 0000000..f3cdfb3
--- /dev/null
+++ b/lib/calendar/feedToken.ts
@@ -0,0 +1,21 @@
+import { createHash, randomBytes } from "node:crypto";
+
+/**
+ * Long-lived, random, rotatable secret for a personal calendar-subscription
+ * URL (/calendar/.ics) — treated like an API key, not a
+ * short-lived link: only its hash is stored, so a database leak alone
+ * can't be used to subscribe to anyone's calendar, and rotating it
+ * (overwriting the stored hash) immediately invalidates the old URL.
+ */
+export function generateCalendarFeedToken(): string {
+ return randomBytes(32).toString("hex");
+}
+
+export function hashCalendarFeedToken(token: string): string {
+ return createHash("sha256").update(token).digest("hex");
+}
+
+export function calendarFeedUrl(token: string): string {
+ const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
+ return `${serverUrl}/calendar/${token}.ics`;
+}
diff --git a/lib/calendar/ics.ts b/lib/calendar/ics.ts
new file mode 100644
index 0000000..78c194a
--- /dev/null
+++ b/lib/calendar/ics.ts
@@ -0,0 +1,72 @@
+import { BERLIN_VTIMEZONE_LINES, toBerlinICSDateTime, toUTCICSStamp } from "./timezone";
+
+export type ICSEvent = {
+ uid: string;
+ start: Date;
+ end: Date;
+ summary: string;
+ description?: string;
+ location?: string;
+ url?: string;
+};
+
+function escapeICSText(value: string): string {
+ return value
+ .replace(/\\/g, "\\\\")
+ .replace(/;/g, "\\;")
+ .replace(/,/g, "\\,")
+ .replace(/\n/g, "\\n");
+}
+
+/** RFC 5545 line folding: lines must not exceed 75 octets; continuations start with a single space. */
+function foldLine(line: string): string {
+ const encoder = new TextEncoder();
+ if (encoder.encode(line).length <= 75) return line;
+
+ const chunks: string[] = [];
+ let current = "";
+ for (const char of line) {
+ const candidate = current + char;
+ if (encoder.encode(candidate).length > 75) {
+ chunks.push(current);
+ current = char;
+ } else {
+ current = candidate;
+ }
+ }
+ if (current) chunks.push(current);
+ return chunks.join("\r\n ");
+}
+
+/** Builds a complete RFC 5545 VCALENDAR document, Europe/Berlin timezone-aware throughout. */
+export function buildICSCalendar(args: { calendarName: string; events: ICSEvent[] }): string {
+ const lines: string[] = [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//ANOUMA//Calendar Feed//DE",
+ "CALSCALE:GREGORIAN",
+ "METHOD:PUBLISH",
+ `X-WR-CALNAME:${escapeICSText(args.calendarName)}`,
+ "X-WR-TIMEZONE:Europe/Berlin",
+ ...BERLIN_VTIMEZONE_LINES,
+ ];
+
+ const stamp = toUTCICSStamp(new Date());
+ for (const ev of args.events) {
+ lines.push(
+ "BEGIN:VEVENT",
+ `UID:${ev.uid}`,
+ `DTSTAMP:${stamp}`,
+ `DTSTART;TZID=Europe/Berlin:${toBerlinICSDateTime(ev.start)}`,
+ `DTEND;TZID=Europe/Berlin:${toBerlinICSDateTime(ev.end)}`,
+ `SUMMARY:${escapeICSText(ev.summary)}`,
+ );
+ if (ev.description) lines.push(`DESCRIPTION:${escapeICSText(ev.description)}`);
+ if (ev.location) lines.push(`LOCATION:${escapeICSText(ev.location)}`);
+ if (ev.url) lines.push(`URL:${ev.url}`);
+ lines.push("STATUS:CONFIRMED", "END:VEVENT");
+ }
+
+ lines.push("END:VCALENDAR");
+ return lines.map(foldLine).join("\r\n") + "\r\n";
+}
diff --git a/lib/calendar/timezone.ts b/lib/calendar/timezone.ts
new file mode 100644
index 0000000..d0160bb
--- /dev/null
+++ b/lib/calendar/timezone.ts
@@ -0,0 +1,57 @@
+const BERLIN_TZ = "Europe/Berlin";
+
+/**
+ * Converts a JS Date (a UTC instant) into its Europe/Berlin wall-clock
+ * representation for iCalendar's local `TZID` date-time form
+ * (`DTSTART;TZID=Europe/Berlin:YYYYMMDDTHHMMSS`). Uses the ICU timezone
+ * database via Intl rather than manual UTC+1/+2 arithmetic, so daylight
+ * saving transitions are always handled correctly — no naive UTC offset
+ * math that would drift by an hour half the year.
+ */
+export function toBerlinICSDateTime(date: Date): string {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: BERLIN_TZ,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hourCycle: "h23",
+ }).formatToParts(date);
+
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00";
+ return `${get("year")}${get("month")}${get("day")}T${get("hour")}${get("minute")}${get("second")}`;
+}
+
+/** UTC timestamp form (`YYYYMMDDTHHMMSSZ`) — used only for DTSTAMP, which marks generation time, not a local event time. */
+export function toUTCICSStamp(date: Date): string {
+ return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
+}
+
+/**
+ * Standard IANA Europe/Berlin VTIMEZONE block (the same one commonly
+ * embedded by calendar exporters): CEST from the last Sunday in March,
+ * CET from the last Sunday in October — the actual EU DST rule, not an
+ * approximation.
+ */
+export const BERLIN_VTIMEZONE_LINES = [
+ "BEGIN:VTIMEZONE",
+ "TZID:Europe/Berlin",
+ "X-LIC-LOCATION:Europe/Berlin",
+ "BEGIN:DAYLIGHT",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+];
diff --git a/lib/email/authTemplates.ts b/lib/email/authTemplates.ts
new file mode 100644
index 0000000..cce35e5
--- /dev/null
+++ b/lib/email/authTemplates.ts
@@ -0,0 +1,14 @@
+import { paragraph, renderButton, renderEmailShell } from "./layout";
+
+export function verificationEmail(args: { name: string; verifyUrl: string; expiresHours: number }): { subject: string; html: string } {
+ const html = renderEmailShell({
+ bodyHtml: [
+ paragraph(`Hallo ${args.name},`),
+ paragraph("dein Konto wurde erstellt.", { italic: true }),
+ paragraph("Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren."),
+ renderButton("E-Mail-Adresse bestätigen", args.verifyUrl),
+ paragraph(`Dieser Link ist ${args.expiresHours} Stunden gültig. Falls du kein Konto erstellt hast, kannst du diese E-Mail ignorieren.`, { small: true }),
+ ].join("\n"),
+ });
+ return { subject: "Bitte bestätige deine E-Mail-Adresse", html };
+}
diff --git a/lib/email/bookingTemplates.ts b/lib/email/bookingTemplates.ts
index 5295eee..6a61be1 100644
--- a/lib/email/bookingTemplates.ts
+++ b/lib/email/bookingTemplates.ts
@@ -98,7 +98,7 @@ export function bookingRejectedEmail(args: BaseArgs): { subject: string; html: s
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
]),
- paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem ANOUMA-Konto vorbei.", { small: true }),
+ paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem Konto vorbei.", { small: true }),
].join("\n"),
});
return { subject: "Deine Terminanfrage bei ANOUMA", html };
diff --git a/lib/payload/globals.ts b/lib/payload/globals.ts
index 03746e2..40eed55 100644
--- a/lib/payload/globals.ts
+++ b/lib/payload/globals.ts
@@ -1,6 +1,6 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
-import type { About, AktuellesIntro, AngeboteIntro, Booking, Contact, Home } from "@/payload-types";
+import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, Home, SeoSetting } from "@/payload-types";
export const getHomeGlobal = cache(async (): Promise => {
const payload = await getCMS();
@@ -31,3 +31,13 @@ export const getBookingGlobal = cache(async (): Promise => {
const payload = await getCMS();
return payload.findGlobal({ slug: "booking" });
});
+
+export const getBookingSettingsGlobal = cache(async (): Promise => {
+ const payload = await getCMS();
+ return payload.findGlobal({ slug: "booking-settings" });
+});
+
+export const getSEOSettingsGlobal = cache(async (): Promise => {
+ const payload = await getCMS();
+ return payload.findGlobal({ slug: "seo-settings" });
+});
diff --git a/lib/seo/config.ts b/lib/seo/config.ts
new file mode 100644
index 0000000..aaf7edc
--- /dev/null
+++ b/lib/seo/config.ts
@@ -0,0 +1,36 @@
+import { getSEOSettingsGlobal } from "@/lib/payload/globals";
+import { siteConfig } from "@/lib/site";
+
+function stripTrailingSlash(url: string): string {
+ return url.replace(/\/+$/, "");
+}
+
+/**
+ * The canonical base URL for the whole site. Prefers the CMS override
+ * (SEO-Einstellungen → Website-URL) when it's a valid absolute URL, then
+ * NEXT_PUBLIC_SERVER_URL (the single source of truth used everywhere else
+ * in this app — emails, meeting links, Payload's own serverURL), then the
+ * hardcoded siteConfig fallback. Never throws — SEO metadata must never
+ * break a page render.
+ */
+export async function getSiteUrl(): Promise {
+ try {
+ const settings = await getSEOSettingsGlobal();
+ if (settings.siteUrl && /^https?:\/\/.+/.test(settings.siteUrl)) {
+ return stripTrailingSlash(settings.siteUrl);
+ }
+ } catch {
+ // CMS/DB unreachable — fall through to the env-based default.
+ }
+ return stripTrailingSlash(process.env.NEXT_PUBLIC_SERVER_URL || siteConfig.domain);
+}
+
+/** Whether the site should be indexable at all (global kill switch in the CMS). */
+export async function isSiteIndexable(): Promise {
+ try {
+ const settings = await getSEOSettingsGlobal();
+ return settings.robotsIndexable !== false;
+ } catch {
+ return true;
+ }
+}
diff --git a/lib/seo/dates.ts b/lib/seo/dates.ts
new file mode 100644
index 0000000..dae9dd3
--- /dev/null
+++ b/lib/seo/dates.ts
@@ -0,0 +1,29 @@
+/**
+ * Formats a JS Date as an ISO 8601 string carrying its actual Europe/Berlin
+ * UTC offset (e.g. "2026-06-15T09:00:00+02:00" in summer,
+ * "...+01:00" in winter) — what schema.org's Event startDate/endDate
+ * expect. Uses Intl's timezone database rather than hardcoded +1/+2 math,
+ * so it's correct across the DST transition automatically.
+ */
+export function toBerlinOffsetISOString(date: Date): string {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: "Europe/Berlin",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hourCycle: "h23",
+ timeZoneName: "shortOffset",
+ }).formatToParts(date);
+
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00";
+ const tzName = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+0";
+ const match = /GMT([+-])(\d+)(?::(\d+))?/.exec(tzName);
+ const sign = match?.[1] ?? "+";
+ const offsetHours = (match?.[2] ?? "0").padStart(2, "0");
+ const offsetMinutes = (match?.[3] ?? "0").padStart(2, "0");
+
+ return `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}${sign}${offsetHours}:${offsetMinutes}`;
+}
diff --git a/lib/seo/jsonld.tsx b/lib/seo/jsonld.tsx
new file mode 100644
index 0000000..37eeb0c
--- /dev/null
+++ b/lib/seo/jsonld.tsx
@@ -0,0 +1,231 @@
+import { siteConfig } from "@/lib/site";
+import { toBerlinOffsetISOString } from "./dates";
+import type { BookingSetting, Contact, Event, Offer, Post, SeoSetting } from "@/payload-types";
+
+// Server-only builders — every function here returns `null` instead of
+// guessing when the real data needed for a valid/honest result isn't
+// present. Nothing here ever invents a review, price, address or phone
+// number (see section 27/35 of the SEO brief).
+
+/** JSON.stringify with `<` escaped so this can never be broken out of by any (currently non-existent) embedded "" sequence. */
+function toSafeJsonLdString(data: unknown): string {
+ return JSON.stringify(data).replace(/;
+
+export function organizationName(settings: SeoSetting | null | undefined): string {
+ return settings?.businessName?.trim() || siteConfig.name;
+}
+
+function hasFullAddress(booking: BookingSetting | null | undefined): booking is BookingSetting & { street: string; city: string; postalCode: string } {
+ return Boolean(booking?.street?.trim() && booking?.city?.trim() && booking?.postalCode?.trim());
+}
+
+function postalAddress(booking: BookingSetting) {
+ return {
+ "@type": "PostalAddress",
+ streetAddress: booking.street!.trim(),
+ postalCode: booking.postalCode!.trim(),
+ addressLocality: booking.city!.trim(),
+ ...(booking.region?.trim() ? { addressRegion: booking.region.trim() } : {}),
+ ...(booking.country?.trim() ? { addressCountry: booking.country.trim() } : {}),
+ };
+}
+
+/**
+ * Organization (always safe — just a name and a URL) upgraded to
+ * ProfessionalService/LocalBusiness once a real, complete street address
+ * has actually been entered in the CMS. Phone/opening hours/geo are added
+ * only when present; nothing is fabricated to "complete" the schema.
+ */
+export function organizationOrLocalBusinessJsonLd(args: {
+ settings: SeoSetting | null | undefined;
+ booking: BookingSetting | null | undefined;
+ contact: Contact | null | undefined;
+ siteUrl: string;
+}): JsonLdValue {
+ const name = organizationName(args.settings);
+ const base: JsonLdValue = {
+ "@context": "https://schema.org",
+ name,
+ url: args.siteUrl,
+ };
+ if (args.settings?.businessDescription?.trim()) base.description = args.settings.businessDescription.trim();
+ if (args.contact?.email) base.email = args.contact.email;
+
+ if (!hasFullAddress(args.booking)) {
+ return { ...base, "@type": "Organization" };
+ }
+
+ const result: JsonLdValue = {
+ ...base,
+ "@type": "ProfessionalService",
+ address: postalAddress(args.booking),
+ };
+
+ if (args.contact?.phone?.trim()) result.telephone = args.contact.phone.trim();
+ if (typeof args.settings?.latitude === "number" && typeof args.settings?.longitude === "number") {
+ result.geo = { "@type": "GeoCoordinates", latitude: args.settings.latitude, longitude: args.settings.longitude };
+ }
+ if (args.settings?.openingHours?.length) {
+ result.openingHoursSpecification = args.settings.openingHours.map((entry) => ({
+ "@type": "OpeningHoursSpecification",
+ dayOfWeek: entry.days.map((day) => `https://schema.org/${day}`),
+ opens: entry.opens,
+ closes: entry.closes,
+ }));
+ }
+ if (args.settings?.socialLinks?.length) {
+ result.sameAs = args.settings.socialLinks.map((link) => link.url).filter(Boolean);
+ }
+
+ return result;
+}
+
+export function websiteJsonLd(siteUrl: string): JsonLdValue {
+ return {
+ "@context": "https://schema.org",
+ "@type": "WebSite",
+ name: siteConfig.name,
+ url: siteUrl,
+ };
+}
+
+export function webPageJsonLd(args: { name: string; description: string; url: string }): JsonLdValue {
+ return {
+ "@context": "https://schema.org",
+ "@type": "WebPage",
+ name: args.name,
+ description: args.description,
+ url: args.url,
+ };
+}
+
+export function breadcrumbJsonLd(items: { name: string; url?: string }[]): JsonLdValue | null {
+ if (items.length === 0) return null;
+ return {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ // The last item's "item" URL is deliberately omitted when absent —
+ // that's the current page, and per Google's guidelines a BreadcrumbList
+ // entry doesn't need a URL for the page the visitor is already on.
+ itemListElement: items.map((item, index) => ({
+ "@type": "ListItem",
+ position: index + 1,
+ name: item.name,
+ ...(item.url ? { item: item.url } : {}),
+ })),
+ };
+}
+
+export function serviceJsonLd(args: {
+ offer: Offer;
+ description: string;
+ imageUrl?: string;
+ siteUrl: string;
+ providerName: string;
+ areaServed?: string;
+}): JsonLdValue {
+ const result: JsonLdValue = {
+ "@context": "https://schema.org",
+ "@type": "Service",
+ name: args.offer.title,
+ description: args.description,
+ url: `${args.siteUrl}/angebote/${args.offer.slug}`,
+ provider: { "@type": "Organization", name: args.providerName, url: args.siteUrl },
+ };
+ if (args.imageUrl) result.image = args.imageUrl;
+ if (args.areaServed) result.areaServed = args.areaServed;
+ // Deliberately no "offers"/price — the CMS "price" field is free text
+ // (e.g. "auf Anfrage") and can't be turned into a valid structured price
+ // without risking incorrect data.
+ return result;
+}
+
+export function eventJsonLd(args: {
+ event: Event;
+ description: string;
+ imageUrl?: string;
+ joinUrl?: string;
+ siteUrl: string;
+ providerName: string;
+ booking: BookingSetting | null | undefined;
+}): JsonLdValue | null {
+ const { event } = args;
+ // Private single-session bookings must never surface as a public Event
+ // (see section 18) — defense in depth alongside the page-level checks
+ // that already keep them out of public listings entirely.
+ if (event.isPrivateBooking) return null;
+ if (!event.date) return null;
+
+ const start = new Date(event.startTime || event.date);
+ const isOnline = Boolean(event.isOnline);
+
+ const result: JsonLdValue = {
+ "@context": "https://schema.org",
+ "@type": "Event",
+ name: event.title,
+ description: args.description,
+ startDate: toBerlinOffsetISOString(start),
+ eventStatus: "https://schema.org/EventScheduled",
+ eventAttendanceMode: isOnline ? "https://schema.org/OnlineEventAttendanceMode" : "https://schema.org/OfflineEventAttendanceMode",
+ url: `${args.siteUrl}/termine/${event.slug}`,
+ organizer: { "@type": "Organization", name: args.providerName, url: args.siteUrl },
+ };
+
+ if (event.endTime) result.endDate = toBerlinOffsetISOString(new Date(event.endTime));
+ if (args.imageUrl) result.image = args.imageUrl;
+
+ if (isOnline) {
+ result.location = { "@type": "VirtualLocation", url: args.joinUrl ?? result.url };
+ } else if (hasFullAddress(args.booking)) {
+ result.location = {
+ "@type": "Place",
+ name: event.location || args.booking.locationName || args.providerName,
+ address: postalAddress(args.booking),
+ };
+ } else if (event.location) {
+ // No structured address on file, but the event itself names a place —
+ // still valid schema.org (address is optional on Place).
+ result.location = { "@type": "Place", name: event.location };
+ }
+
+ return result;
+}
+
+export function articleJsonLd(args: {
+ post: Post;
+ description: string;
+ imageUrl?: string;
+ siteUrl: string;
+ providerName: string;
+}): JsonLdValue {
+ const url = `${args.siteUrl}/aktuelles/${args.post.slug}`;
+ return {
+ "@context": "https://schema.org",
+ "@type": "BlogPosting",
+ headline: args.post.title,
+ description: args.description,
+ url,
+ mainEntityOfPage: url,
+ datePublished: new Date(args.post.publishDate ?? args.post.createdAt).toISOString(),
+ dateModified: new Date(args.post.updatedAt).toISOString(),
+ author: { "@type": "Person", name: args.post.author?.trim() || args.providerName },
+ publisher: { "@type": "Organization", name: args.providerName, url: args.siteUrl },
+ ...(args.imageUrl ? { image: args.imageUrl } : {}),
+ };
+}
+
+/** Renders one or more JSON-LD objects as server-side