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