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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:57:31 +02:00
co-authored by Claude Sonnet 5
parent 50c39a70e0
commit 1cd15aff25
72 changed files with 2835 additions and 257 deletions
+73 -23
View File
@@ -1,29 +1,79 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
const routes = [
"",
"/ueber-mich",
"/angebote",
"/angebote/prozessbegleitung",
"/angebote/doula-begleitung",
"/angebote/erdenkinder",
"/angebote/maedchenkreis",
"/angebote/singkreise",
"/angebote/singkreise/singen-im-kreis",
"/angebote/singkreise/singen-fuer-schwangere",
"/angebote/singkreise/mama-baby-singkreis",
"/aktuelles",
"/termin-buchen",
"/kontakt",
"/impressionen",
// Reads live CMS content on every request — must not be statically
// prerendered at build time (no DB is available during `next build`; see
// AGENTS.md / the same convention used by every other CMS-backed route).
export const dynamic = "force-dynamic";
// Static, always-public routes that aren't backed by a dynamic [slug]
// collection. Deliberately excludes: /login, /registrieren, /konto*,
// /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
// /api/* — none of those are public content (see app/robots.ts, which
// mirrors this same exclusion list).
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
{ path: "", changeFrequency: "weekly", priority: 1 },
{ path: "/ueber-mich", changeFrequency: "monthly", priority: 0.7 },
{ path: "/angebote", changeFrequency: "monthly", priority: 0.9 },
{ path: "/angebote/singkreise", changeFrequency: "monthly", priority: 0.6 },
{ path: "/aktuelles", changeFrequency: "weekly", priority: 0.7 },
{ path: "/termine", changeFrequency: "weekly", priority: 0.7 },
{ path: "/termin-buchen", changeFrequency: "monthly", priority: 0.8 },
{ path: "/kontakt", changeFrequency: "yearly", priority: 0.5 },
{ path: "/impressionen", changeFrequency: "monthly", priority: 0.3 },
{ path: "/impressum", changeFrequency: "yearly", priority: 0.1 },
{ path: "/datenschutz", changeFrequency: "yearly", priority: 0.1 },
];
export default function sitemap(): MetadataRoute.Sitemap {
return routes.map((route) => ({
url: `${siteConfig.domain}${route}`,
lastModified: new Date(),
changeFrequency: route === "" ? "weekly" : "monthly",
priority: route === "" ? 1 : 0.7,
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
if (!(await isSiteIndexable())) return [];
const siteUrl = await getSiteUrl();
const now = new Date();
const entries: MetadataRoute.Sitemap = staticRoutes.map((route) => ({
url: `${siteUrl}${route.path}`,
lastModified: now,
changeFrequency: route.changeFrequency,
priority: route.priority,
}));
// getOffers/getAllEvents/getPosts already only return published,
// publicly-readable documents (overrideAccess: false — see
// lib/payload/content.ts's header comment).
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
for (const offer of offers) {
if (!offer.slug || offer.visibility === "private") continue;
entries.push({
url: `${siteUrl}/angebote/${offer.slug}`,
lastModified: new Date(offer.updatedAt),
changeFrequency: "monthly",
priority: 0.8,
});
}
for (const event of events) {
// Private single-session bookings must never appear in the sitemap.
if (!event.slug || event.isPrivateBooking) continue;
entries.push({
url: `${siteUrl}/termine/${event.slug}`,
lastModified: new Date(event.updatedAt),
changeFrequency: "weekly",
priority: 0.6,
});
}
for (const post of posts) {
if (!post.slug) continue;
entries.push({
url: `${siteUrl}/aktuelles/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "monthly",
priority: 0.6,
});
}
return entries;
}