- 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>
80 lines
3.1 KiB
TypeScript
80 lines
3.1 KiB
TypeScript
import type { MetadataRoute } from "next";
|
|
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
|
|
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
|
|
|
// 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 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;
|
|
}
|