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
+70
View File
@@ -0,0 +1,70 @@
/**
* Optional, dev-only SEO sanity check — NOT part of the build or deploy
* pipeline, never blocks production. Run manually:
*
* npm run seo:check
*
* Only ever warns; exits 0 regardless of findings (a content gap here isn't
* a broken deploy).
*/
import { getPayload } from "payload";
import config from "../payload.config";
let warnings = 0;
function warn(message: string) {
warnings++;
console.warn(`${message}`);
}
async function checkCollection(
payload: Awaited<ReturnType<typeof getPayload>>,
collection: "offers" | "events" | "posts",
label: string,
) {
console.log(`${label}:`);
const { docs } = await payload.find({ collection, limit: 500, depth: 0 });
const slugCounts = new Map<string, number>();
for (const doc of docs as { slug?: string | null; title?: string }[]) {
if (doc.slug) slugCounts.set(doc.slug, (slugCounts.get(doc.slug) ?? 0) + 1);
}
for (const [slug, count] of slugCounts) {
if (count > 1) warn(`Slug „${slug}“ kommt ${count}× vor.`);
}
for (const doc of docs as { slug?: string | null; title?: string; seo?: { description?: string | null } | null }[]) {
if (!doc.title?.trim()) warn(`Dokument ohne Titel (slug: ${doc.slug ?? "—"}).`);
if (!doc.slug?.trim()) warn(`Dokument ohne Slug: „${doc.title ?? "—"}“.`);
}
if (docs.length === 0) console.log(" (keine Einträge)");
}
async function checkMedia(payload: Awaited<ReturnType<typeof getPayload>>) {
console.log("Medien (Alt-Texte):");
const { docs } = await payload.find({ collection: "media", limit: 1000, depth: 0 });
const missingAlt = (docs as { alt?: string; filename?: string }[]).filter((doc) => !doc.alt?.trim());
if (missingAlt.length === 0) {
console.log(" Alle Bilder haben einen Alt-Text.");
} else {
for (const doc of missingAlt) warn(`Bild ohne Alt-Text: ${doc.filename ?? "unbekannt"}`);
}
}
async function run() {
const payload = await getPayload({ config });
await checkCollection(payload, "offers", "Angebote");
await checkCollection(payload, "events", "Termine");
await checkCollection(payload, "posts", "Aktuelles");
await checkMedia(payload);
console.log(`\n${warnings === 0 ? "Keine Auffälligkeiten gefunden." : `${warnings} Hinweis(e) gefunden — siehe oben.`}`);
process.exit(0);
}
run().catch((err) => {
console.error(err);
process.exit(0);
});