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:
+73
-23
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user