diff --git a/app/(frontend)/aktuelles/[slug]/page.tsx b/app/(frontend)/aktuelles/[slug]/page.tsx index 42ebbbc..bb798e1 100644 --- a/app/(frontend)/aktuelles/[slug]/page.tsx +++ b/app/(frontend)/aktuelles/[slug]/page.tsx @@ -1,26 +1,61 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect, redirect } from "next/navigation"; import { PageHeader } from "@/components/PageHeader"; import { Section } from "@/components/Section"; import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { RichText } from "@/components/RichText"; import { getPostBySlug } from "@/lib/payload/content"; import { mediaAlt, mediaUrl } from "@/lib/payload/media"; +import { getSEOSettingsGlobal } from "@/lib/payload/globals"; +import { resolveSeo } from "@/lib/seo/resolve"; +import { buildMetadata } from "@/lib/seo/metadata"; +import { getSiteUrl } from "@/lib/seo/config"; +import { excerptFromRichText } from "@/lib/seo/textExcerpt"; +import { JsonLd, articleJsonLd } from "@/lib/seo/jsonld"; +import { resolveRedirect } from "@/lib/seo/redirects"; +import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; type Args = { params: Promise<{ slug: string }> }; +async function resolvePostOrRedirect(slug: string) { + const post = await getPostBySlug(slug); + if (post) return post; + + const match = await resolveRedirect(`/aktuelles/${slug}`); + if (match) { + if (match.permanent) permanentRedirect(match.to); + redirect(match.to); + } + return null; +} + export async function generateMetadata({ params }: Args): Promise { const { slug } = await params; - const post = await getPostBySlug(slug); + const [post, seoSettings] = await Promise.all([getPostBySlug(slug), getSEOSettingsGlobal()]); if (!post) return {}; - return { title: post.title, description: post.teaser }; + + const resolved = resolveSeo({ + seo: post.seo, + fallbackTitle: `${post.title} – ${siteConfig.name}`, + fallbackDescription: () => post.teaser, + fallbackImage: post.coverImage, + defaultOgImage: seoSettings.defaultOgImage, + }); + return buildMetadata({ + title: resolved.title, + description: resolved.description, + path: `/aktuelles/${post.slug}`, + ogImageUrl: resolved.ogImageUrl, + keywords: resolved.keywords, + type: "article", + }); } export default async function PostDetailPage({ params }: Args) { const { slug } = await params; - const post = await getPostBySlug(slug); + const post = await resolvePostOrRedirect(slug); if (!post) notFound(); const publishDate = new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", { @@ -28,9 +63,19 @@ export default async function PostDetailPage({ params }: Args) { month: "long", year: "numeric", }); + const siteUrl = await getSiteUrl(); return ( <> + { - const intro = await getAktuellesIntroGlobal(); - return { - title: intro.title || "Aktuelles", - description: intro.lead || "Neuigkeiten, Termine und Inspiration von Anouma.", - }; + const [intro, seoSettings] = await Promise.all([getAktuellesIntroGlobal(), getSEOSettingsGlobal()]); + const resolved = resolveSeo({ + seo: intro.seo, + fallbackTitle: "Aktuelles", + fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION, + defaultOgImage: seoSettings.defaultOgImage, + }); + return buildMetadata({ + title: resolved.title, + description: resolved.description, + path: "/aktuelles", + ogImageUrl: resolved.ogImageUrl, + keywords: resolved.keywords, + }); } export default async function AktuellesPage() { const [intro, posts] = await Promise.all([getAktuellesIntroGlobal(), getPosts()]); + const siteUrl = await getSiteUrl(); return ( <> + }; +async function resolveOfferOrRedirect(slug: string) { + const offer = await getOfferBySlug(slug); + if (offer) return offer; + + const match = await resolveRedirect(`/angebote/${slug}`); + if (match) { + if (match.permanent) permanentRedirect(match.to); + redirect(match.to); + } + return null; +} + export async function generateMetadata({ params }: Args): Promise { const { slug } = await params; - const offer = await getOfferBySlug(slug); + const [offer, seoSettings] = await Promise.all([getOfferBySlug(slug), getSEOSettingsGlobal()]); if (!offer) return {}; - return { - title: offer.title, - description: offer.shortDescription, - }; + + const resolved = resolveSeo({ + seo: offer.seo, + fallbackTitle: `${offer.title} – ${siteConfig.name}`, + fallbackDescription: () => offer.shortDescription, + fallbackImage: offer.image, + defaultOgImage: seoSettings.defaultOgImage, + }); + return buildMetadata({ + title: resolved.title, + description: resolved.description, + path: `/angebote/${offer.slug}`, + ogImageUrl: resolved.ogImageUrl, + keywords: resolved.keywords, + // Private single-session offers (e.g. "Einzelbegleitung") stay reachable + // via their direct link but must never be indexed or listed. + noindex: offer.visibility === "private", + }); } export default async function OfferDetailPage({ params }: Args) { const { slug } = await params; - const offer = await getOfferBySlug(slug); + const offer = await resolveOfferOrRedirect(slug); if (!offer) notFound(); const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label; const customer = offer.bookable ? await getCurrentCustomer() : null; + const siteUrl = await getSiteUrl(); return ( <> + {offer.visibility !== "private" && ( + + )}

Termin auswählen

- +
) : ( diff --git a/app/(frontend)/angebote/page.tsx b/app/(frontend)/angebote/page.tsx index 5f00b57..5b28a14 100644 --- a/app/(frontend)/angebote/page.tsx +++ b/app/(frontend)/angebote/page.tsx @@ -7,25 +7,41 @@ import { Button } from "@/components/Button"; import { CTA } from "@/components/CTA"; import { Reveal } from "@/components/Reveal"; import { getOffers } from "@/lib/payload/content"; -import { getAngeboteIntroGlobal } from "@/lib/payload/globals"; +import { getAngeboteIntroGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals"; import { groupOffersByCategory, moodForOffer } from "@/lib/angebote"; import { mediaAlt, mediaUrl } from "@/lib/payload/media"; +import { resolveSeo } from "@/lib/seo/resolve"; +import { buildMetadata } from "@/lib/seo/metadata"; +import { getSiteUrl } from "@/lib/seo/config"; +import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld"; +import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; +const FALLBACK_DESCRIPTION = + "Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick."; + export async function generateMetadata(): Promise { - const intro = await getAngeboteIntroGlobal(); - return { - title: intro.title || "Angebote", - description: - intro.lead || - "Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.", - }; + const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]); + const resolved = resolveSeo({ + seo: intro.seo, + fallbackTitle: `Begleitung für dich und deine Familie – ${siteConfig.name}`, + fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION, + defaultOgImage: seoSettings.defaultOgImage, + }); + return buildMetadata({ + title: resolved.title, + description: resolved.description, + path: "/angebote", + ogImageUrl: resolved.ogImageUrl, + keywords: resolved.keywords, + }); } export default async function AngebotePage() { const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]); const groups = groupOffersByCategory(offers); + const siteUrl = await getSiteUrl(); const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0]; const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0]; @@ -34,6 +50,13 @@ export default async function AngebotePage() { return ( <> + { + return buildMetadata({ + title: `Singkreise – ${siteConfig.name}`, + description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.", + path: "/angebote/singkreise", + }); +} export default async function SingkreisePage() { const offers = await getOffers(); diff --git a/app/(frontend)/auth/verify-email/[token]/page.tsx b/app/(frontend)/auth/verify-email/[token]/page.tsx new file mode 100644 index 0000000..363631f --- /dev/null +++ b/app/(frontend)/auth/verify-email/[token]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { PageHeader } from "@/components/PageHeader"; +import { Section } from "@/components/Section"; +import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton"; +import { verifyEmailToken } from "@/lib/auth/verifyEmailToken"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "E-Mail bestätigen", robots: { index: false, follow: false } }; + +type Args = { params: Promise<{ token: string }> }; + +export default async function VerifyEmailPage({ params }: Args) { + const { token } = await params; + const verified = await verifyEmailToken(token); + + return ( + <> + +
+
+ {verified ? ( + <> +

Dein Konto ist jetzt aktiviert.

+ + Zum Konto + + + ) : ( + <> +

+ Dieser Verifizierungslink ist ungültig oder abgelaufen. +

+
+ + + Zur Anmeldung + +
+ + )} +
+
+ + ); +} diff --git a/app/(frontend)/calendar/[tokenFile]/route.ts b/app/(frontend)/calendar/[tokenFile]/route.ts new file mode 100644 index 0000000..4f0b9a9 --- /dev/null +++ b/app/(frontend)/calendar/[tokenFile]/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { getCMS } from "@/lib/payload/getPayload"; +import { hashCalendarFeedToken } from "@/lib/calendar/feedToken"; +import { buildICSCalendar } from "@/lib/calendar/ics"; +import { getAdminFeedICSEvents } from "@/lib/calendar/adminFeed"; + +export const dynamic = "force-dynamic"; + +const TOKEN_SHAPE = /^[0-9a-f]{64}$/i; + +type Args = { params: Promise<{ tokenFile: string }> }; + +/** + * GET /calendar/.ics — a private iCalendar feed, subscribable from + * Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token + * itself is the only credential (there's no login here), so it's treated + * like a secret: looked up by hash only, never logged, and the response is + * marked non-cacheable-by-proxies and non-indexable. + */ +export async function GET(_request: Request, { params }: Args) { + const { tokenFile } = await params; + if (!tokenFile.endsWith(".ics")) { + return new NextResponse("Not found", { status: 404 }); + } + const token = tokenFile.slice(0, -".ics".length); + if (!TOKEN_SHAPE.test(token)) { + return new NextResponse("Not found", { status: 404 }); + } + + const payload = await getCMS(); + const hash = hashCalendarFeedToken(token); + + const { docs } = await payload.find({ + collection: "users", + where: { calendarFeedTokenHash: { equals: hash } }, + limit: 1, + }); + // Section 15 / later extension: once normal user accounts also get a + // personal feed, a matching lookup against "customers" would go here, + // returning that customer's own bookings instead of the admin feed below. + const owner = docs[0]; + + if (!owner) { + return new NextResponse("Not found", { status: 404, headers: { "X-Robots-Tag": "noindex, nofollow" } }); + } + + const events = await getAdminFeedICSEvents(payload); + const ics = buildICSCalendar({ calendarName: "Persönlicher Kalender", events }); + + return new NextResponse(ics, { + status: 200, + headers: { + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": 'inline; filename="kalender.ics"', + "X-Robots-Tag": "noindex, nofollow", + "Cache-Control": "private, no-store", + }, + }); +} diff --git a/app/(frontend)/datenschutz/page.tsx b/app/(frontend)/datenschutz/page.tsx index db185a7..79499fc 100644 --- a/app/(frontend)/datenschutz/page.tsx +++ b/app/(frontend)/datenschutz/page.tsx @@ -3,14 +3,19 @@ import { PageHeader } from "@/components/PageHeader"; import { Section } from "@/components/Section"; import { PlaceholderNote } from "@/components/PlaceholderNote"; import { getContactGlobal } from "@/lib/payload/globals"; +import { getSiteUrl } from "@/lib/seo/config"; export const dynamic = "force-dynamic"; -export const metadata: Metadata = { - title: "Datenschutz", - description: "Datenschutzerklärung von Anouma.", - robots: { index: false, follow: true }, -}; +export async function generateMetadata(): Promise { + const siteUrl = await getSiteUrl(); + return { + title: "Datenschutz", + description: "Datenschutzerklärung von Anouma.", + robots: { index: false, follow: true }, + alternates: { canonical: `${siteUrl}/datenschutz` }, + }; +} export default async function DatenschutzPage() { const contact = await getContactGlobal(); diff --git a/app/(frontend)/impressionen/page.tsx b/app/(frontend)/impressionen/page.tsx index df8589f..78619e5 100644 --- a/app/(frontend)/impressionen/page.tsx +++ b/app/(frontend)/impressionen/page.tsx @@ -4,11 +4,17 @@ import { Section } from "@/components/Section"; import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { Reveal } from "@/components/Reveal"; import type { ImageMood } from "@/lib/angebote"; +import { buildMetadata } from "@/lib/seo/metadata"; +import { getSiteUrl } from "@/lib/seo/config"; +import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld"; -export const metadata: Metadata = { - title: "Impressionen", - description: "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.", -}; +export const dynamic = "force-dynamic"; + +const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume."; + +export async function generateMetadata(): Promise { + return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" }); +} const gallery: { mood: ImageMood; label: string; span?: string }[] = [ { mood: "moss", label: "Wald und Naturverbundenheit", span: "sm:row-span-2" }, @@ -21,9 +27,12 @@ const gallery: { mood: ImageMood; label: string; span?: string }[] = [ { mood: "mauve", label: "Räume der Verbindung" }, ]; -export default function ImpressionenPage() { +export default async function ImpressionenPage() { + const siteUrl = await getSiteUrl(); + return ( <> + { + const siteUrl = await getSiteUrl(); + return { + title: "Impressum", + description: "Impressum von Anouma gemäß § 5 TMG.", + // noindex, but still crawlable — legal boilerplate deliberately kept + // out of search results without cutting off the internal links on it. + robots: { index: false, follow: true }, + alternates: { canonical: `${siteUrl}/impressum` }, + }; +} export default async function ImpressumPage() { const contact = await getContactGlobal(); diff --git a/app/(frontend)/kontakt/page.tsx b/app/(frontend)/kontakt/page.tsx index aadf460..8455b95 100644 --- a/app/(frontend)/kontakt/page.tsx +++ b/app/(frontend)/kontakt/page.tsx @@ -4,23 +4,47 @@ import { Section } from "@/components/Section"; import { ContactForm } from "@/components/ContactForm"; import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { Reveal } from "@/components/Reveal"; -import { getContactGlobal } from "@/lib/payload/globals"; +import { getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals"; +import { resolveSeo } from "@/lib/seo/resolve"; +import { buildMetadata } from "@/lib/seo/metadata"; +import { getSiteUrl } from "@/lib/seo/config"; +import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld"; +import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; +const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören."; + export async function generateMetadata(): Promise { - const contact = await getContactGlobal(); - return { - title: contact.title || "Kontakt", - description: contact.lead || "Ich freue mich, von dir zu hören.", - }; + const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]); + const resolved = resolveSeo({ + seo: contact.seo, + fallbackTitle: `Kontakt – ${siteConfig.name}`, + fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION, + defaultOgImage: seoSettings.defaultOgImage, + }); + return buildMetadata({ + title: resolved.title, + description: resolved.description, + path: "/kontakt", + ogImageUrl: resolved.ogImageUrl, + keywords: resolved.keywords, + }); } export default async function KontaktPage() { const contact = await getContactGlobal(); + const siteUrl = await getSiteUrl(); return ( <> + Mein Konto

Hallo, {customer.name}

+ {!customer.emailVerified && ( +
+

Bitte bestätige zuerst deine E-Mail-Adresse.

+

+ Erst danach kannst du Termine anfragen. Prüfe dein Postfach oder fordere unten eine neue E-Mail an. +

+ +
+ )}
diff --git a/app/robots.ts b/app/robots.ts index ae5072b..2e905c4 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,15 +1,43 @@ import type { MetadataRoute } from "next"; -import { siteConfig } from "@/lib/site"; +import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config"; + +// Reads the CMS's SEO settings (site URL override, robots kill switch) on +// every request — must not be statically prerendered at build time. +export const dynamic = "force-dynamic"; + +export default async function robots(): Promise { + const [siteUrl, indexable] = await Promise.all([getSiteUrl(), isSiteIndexable()]); + + if (!indexable) { + // Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen + // sichtbar") — block everything rather than list exceptions. + return { rules: [{ userAgent: "*", disallow: "/" }] }; + } -export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: "/", - disallow: ["/impressum", "/datenschutz"], + disallow: [ + // Admin panel and its API. + "/admin", + "/admin/*", + "/api/*", + // Customer accounts — never public. + "/konto", + "/konto/*", + "/login", + "/registrieren", + "/auth/*", + // Private calendar-subscription feed (secret-token URLs). + "/calendar/*", + // Meeting join/call flow — session-gated, never a page worth indexing. + "/termine/*/beitreten", + "/termine/*/call", + ], }, ], - sitemap: `${siteConfig.domain}/sitemap.xml`, + sitemap: `${siteUrl}/sitemap.xml`, }; } diff --git a/app/sitemap.ts b/app/sitemap.ts index 0c7ad1d..086b4bb 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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 { + 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; } diff --git a/collections/Customers.ts b/collections/Customers.ts index 91b9451..dbccd71 100644 --- a/collections/Customers.ts +++ b/collections/Customers.ts @@ -1,4 +1,8 @@ -import type { CollectionConfig } from "payload"; +import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload"; +import { isAdminFieldLevel } from "@/access"; +import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification"; +import { verificationEmail } from "@/lib/email/authTemplates"; +import { sendEmail } from "@/lib/email/sendBookingEmails"; const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => { const user = req.user; @@ -8,6 +12,41 @@ const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unkn return false; }; +// Runs on every new customer account: issues a random, single-use, +// time-limited email-verification token. Only its hash is ever persisted +// (see the emailVerificationTokenHash field below) — the plaintext exists +// only in req.context for the afterChange hook below to email out, and is +// never written to the database or logged. +const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => { + if (operation !== "create") return data; + const token = generateVerificationToken(); + data.emailVerified = false; + data.emailVerificationTokenHash = hashVerificationToken(token); + data.emailVerificationExpires = verificationExpiryISO(); + req.context.pendingVerificationToken = token; + return data; +}; + +const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => { + if (operation !== "create") return doc; + const token = req.context.pendingVerificationToken; + if (typeof token !== "string") return doc; + try { + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000"; + await sendEmail( + doc.email, + verificationEmail({ + name: doc.name, + verifyUrl: `${serverUrl}/auth/verify-email/${token}`, + expiresHours: Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000)), + }), + ); + } catch (err) { + req.payload.logger.error({ err, msg: "customers sendVerificationEmail failed" }); + } + return doc; +}; + export const Customers: CollectionConfig = { slug: "customers", labels: { @@ -32,6 +71,10 @@ export const Customers: CollectionConfig = { update: isSelfOrAdmin, delete: ({ req }) => req.user?.collection === "users", }, + hooks: { + beforeChange: [issueVerificationToken], + afterChange: [sendVerificationEmail], + }, fields: [ { name: "name", @@ -48,6 +91,35 @@ export const Customers: CollectionConfig = { description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.", }, }, + { + name: "emailVerified", + type: "checkbox", + label: "E-Mail bestätigt", + defaultValue: false, + // "create" AND "update" must both be locked down — otherwise a + // customer could simply include emailVerified: true in their own + // registration or profile request and skip verification entirely. + access: { create: isAdminFieldLevel, update: isAdminFieldLevel }, + admin: { + position: "sidebar", + description: "Wird automatisch gesetzt, sobald der Bestätigungslink aus der E-Mail angeklickt wird. Kann hier bei Bedarf manuell gesetzt werden.", + }, + }, + { + name: "emailVerificationTokenHash", + type: "text", + // Never exposed or settable via any API — only the two hooks above + // (running through the trusted Local API) ever touch this field. + access: { create: () => false, read: () => false, update: () => false }, + admin: { hidden: true }, + index: true, + }, + { + name: "emailVerificationExpires", + type: "date", + access: { create: () => false, read: () => false, update: () => false }, + admin: { hidden: true }, + }, // "email" and "password" are added automatically by the auth config. ], }; diff --git a/collections/Events.ts b/collections/Events.ts index 08b11a8..3389b82 100644 --- a/collections/Events.ts +++ b/collections/Events.ts @@ -1,6 +1,7 @@ import type { CollectionBeforeChangeHook, CollectionConfig } from "payload"; import { isAdmin, publishedOrAdmin } from "@/access"; import { slugField } from "@/fields/slug"; +import { seoFields } from "@/fields/seo"; import { generateMeetingPassword } from "@/lib/meeting/password"; export const EVENT_CATEGORIES = [ @@ -243,5 +244,6 @@ export const Events: CollectionConfig = { label: "Zugehörige Buchungsanfrage", admin: { position: "sidebar", readOnly: true }, }, + seoFields(), ], }; diff --git a/collections/Offers.ts b/collections/Offers.ts index df9d517..66d2b96 100644 --- a/collections/Offers.ts +++ b/collections/Offers.ts @@ -1,6 +1,7 @@ import type { CollectionConfig } from "payload"; import { isAdmin, publishedOrAdmin } from "@/access"; import { slugField } from "@/fields/slug"; +import { seoFields } from "@/fields/seo"; // Groups the 7 individual offers for the mega menu and the /angebote // overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise @@ -123,5 +124,6 @@ export const Offers: CollectionConfig = { }, ], }, + seoFields(), ], }; diff --git a/collections/Posts.ts b/collections/Posts.ts index ffc81f6..93629ac 100644 --- a/collections/Posts.ts +++ b/collections/Posts.ts @@ -1,6 +1,7 @@ import type { CollectionConfig } from "payload"; import { isAdmin, publishedOrAdmin } from "@/access"; import { slugField } from "@/fields/slug"; +import { seoFields } from "@/fields/seo"; export const Posts: CollectionConfig = { slug: "posts", @@ -64,5 +65,13 @@ export const Posts: CollectionConfig = { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, }, }, + { + name: "author", + type: "text", + label: "Autor:in", + defaultValue: "Anouma", + admin: { position: "sidebar" }, + }, + seoFields(), ], }; diff --git a/collections/Redirects.ts b/collections/Redirects.ts new file mode 100644 index 0000000..a40d272 --- /dev/null +++ b/collections/Redirects.ts @@ -0,0 +1,76 @@ +import type { CollectionConfig } from "payload"; +import { isAdmin } from "@/access"; + +function normalizePath(value: string): string { + const trimmed = value.trim(); + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash; +} + +/** + * Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately + * scoped to the three dynamic [slug] detail routes (Angebote/Termine/ + * Aktuelles) — that's the only place slugs actually live and can change, so + * looking this up only there avoids adding a database lookup to every + * single request the way a catch-all middleware would. + */ +export const Redirects: CollectionConfig = { + slug: "redirects", + labels: { + singular: "Weiterleitung", + plural: "Weiterleitungen", + }, + admin: { + useAsTitle: "fromPath", + defaultColumns: ["fromPath", "toPath", "type", "enabled"], + group: "SEO", + description: "Leitet eine alte URL (z. B. nach einer Slug-Änderung bei Angeboten, Terminen oder Aktuelles) dauerhaft auf eine neue weiter.", + }, + access: { + read: () => true, + create: isAdmin, + update: isAdmin, + delete: isAdmin, + }, + fields: [ + { + name: "fromPath", + type: "text", + label: "Alte URL (Pfad)", + required: true, + unique: true, + index: true, + admin: { description: "Nur der Pfad, z. B. /angebote/doula (ohne Domain)." }, + hooks: { + beforeValidate: [({ value }) => (typeof value === "string" && value ? normalizePath(value) : value)], + }, + }, + { + name: "toPath", + type: "text", + label: "Neue URL (Pfad)", + required: true, + admin: { description: "Ziel-Pfad, z. B. /angebote/doula-begleitung." }, + hooks: { + beforeValidate: [({ value }) => (typeof value === "string" && value ? normalizePath(value) : value)], + }, + }, + { + name: "type", + type: "select", + label: "Art der Weiterleitung", + required: true, + defaultValue: "permanent", + options: [ + { label: "301 – Dauerhaft", value: "permanent" }, + { label: "302 – Vorübergehend", value: "temporary" }, + ], + }, + { + name: "enabled", + type: "checkbox", + label: "Aktiv", + defaultValue: true, + }, + ], +}; diff --git a/collections/Users.ts b/collections/Users.ts index 19a64f5..b1c846f 100644 --- a/collections/Users.ts +++ b/collections/Users.ts @@ -45,6 +45,17 @@ export const Users: CollectionConfig = { update: isAdminFieldLevel, }, }, + { + name: "calendarFeedTokenHash", + type: "text", + // Only ever set by /api/admin/calendar-feed/regenerate (see + // lib/calendar/feedToken.ts) — never exposed or directly settable via + // any API, so the plaintext token is never retrievable again after + // it's shown once at generation time. + access: { create: () => false, read: () => false, update: () => false }, + admin: { hidden: true }, + index: true, + }, // "email" and "password" are added automatically by `auth: true`-style config. ], }; diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx index fadf5f7..b0a4621 100644 --- a/components/PageHeader.tsx +++ b/components/PageHeader.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs"; import { OrganicBlob } from "@/components/OrganicBlob"; +import { JsonLd, breadcrumbJsonLd } from "@/lib/seo/jsonld"; +import { getSiteUrl } from "@/lib/seo/config"; type PageHeaderProps = { eyebrow?: string; @@ -9,11 +11,24 @@ type PageHeaderProps = { crumbs?: Crumb[]; }; -export function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) { +/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` — every page that already passes `crumbs` gets structured breadcrumb data for free. */ +export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) { + const breadcrumbData = crumbs?.length + ? await (async () => { + const siteUrl = await getSiteUrl(); + const items = [ + { name: "Startseite", url: siteUrl }, + ...crumbs.map((crumb) => ({ name: crumb.title, url: crumb.href ? `${siteUrl}${crumb.href}` : undefined })), + ]; + return breadcrumbJsonLd(items); + })() + : null; + return (
+ {breadcrumbData && } {crumbs && } {eyebrow && (

diff --git a/components/admin/AdminCalendarView.tsx b/components/admin/AdminCalendarView.tsx index c88640d..a53f774 100644 --- a/components/admin/AdminCalendarView.tsx +++ b/components/admin/AdminCalendarView.tsx @@ -1,5 +1,6 @@ import type { AdminViewServerProps } from "payload"; import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar"; +import { CalendarFeedPanel } from "./CalendarFeedPanel"; import styles from "./AdminCalendarView.module.css"; const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; @@ -58,6 +59,12 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps rangeEnd.setDate(rangeEnd.getDate() + 41); // 6 full weeks const items = await getAdminCalendarItems(req.payload, { from: rangeStart, to: rangeEnd }); + + // Local API (overrideAccess: true by default) so this can see + // calendarFeedTokenHash despite its field-level access being locked to + // "nobody" for every real API path — see collections/Users.ts. + const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null; + const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash); const itemsByDay = new Map(); for (const item of items) { const key = dateKey(new Date(item.date)); @@ -134,6 +141,8 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps ))}

+ +
diff --git a/components/admin/CalendarFeedPanel.module.css b/components/admin/CalendarFeedPanel.module.css new file mode 100644 index 0000000..fdb2096 --- /dev/null +++ b/components/admin/CalendarFeedPanel.module.css @@ -0,0 +1,122 @@ +.panel { + margin-top: 28px; + border: 1px solid #e6e6e6; + border-radius: 12px; + padding: 20px 22px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.heading { + font-size: 16px; + font-weight: 600; + margin: 0 0 4px; + color: #1f1f1f; +} + +.hint { + font-size: 13px; + color: #666; + margin: 0 0 14px; + line-height: 1.5; +} + +.status { + font-size: 13px; + color: #555; + margin: 0 0 14px; +} + +.button { + display: inline-flex; + align-items: center; + border: none; + border-radius: 999px; + background: #8b616d; + color: #fff; + font-size: 13px; + font-weight: 600; + padding: 9px 18px; + cursor: pointer; +} +.button:hover { + background: #66505f; +} +.button:disabled { + opacity: 0.6; + cursor: default; +} + +.secondaryButton { + display: inline-flex; + align-items: center; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + padding: 9px 18px; + cursor: pointer; + background: transparent; + color: #8b616d; + border: 1px solid #8b616d; +} +.secondaryButton:hover { + background: #f7f0f2; +} +.secondaryButton:disabled { + opacity: 0.6; + cursor: default; +} + +.result { + margin-top: 16px; + border-radius: 10px; + background: #f7f4f1; + padding: 14px 16px; +} + +.resultWarning { + font-size: 12px; + font-weight: 600; + color: #7a2020; + margin: 0 0 8px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.urlRow { + display: flex; + gap: 8px; + align-items: center; +} + +.urlInput { + flex: 1; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #dcdcdc; + background: #fff; + color: #333; +} + +.copied { + font-size: 12px; + color: #3a4f30; + margin: 8px 0 0; +} + +.error { + font-size: 13px; + color: #7a2020; + margin-top: 10px; +} + +.instructions { + margin-top: 18px; + font-size: 12px; + color: #777; + line-height: 1.6; +} +.instructions strong { + color: #444; +} diff --git a/components/admin/CalendarFeedPanel.tsx b/components/admin/CalendarFeedPanel.tsx new file mode 100644 index 0000000..7a350d3 --- /dev/null +++ b/components/admin/CalendarFeedPanel.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import styles from "./CalendarFeedPanel.module.css"; + +export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured: boolean }) { + const [configured, setConfigured] = useState(initiallyConfigured); + const [loading, setLoading] = useState(false); + const [url, setUrl] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + async function generate() { + setLoading(true); + setError(null); + setCopied(false); + try { + const res = await fetch("/api/admin/calendar-feed/regenerate", { method: "POST", credentials: "include" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || "Der Kalender-Link konnte nicht erzeugt werden."); + return; + } + setUrl(data.url); + setConfigured(true); + } catch { + setError("Verbindung fehlgeschlagen."); + } finally { + setLoading(false); + } + } + + async function copy() { + if (!url) return; + try { + await navigator.clipboard.writeText(url); + setCopied(true); + } catch { + // Clipboard API can be unavailable (e.g. insecure context) — the + // input is still selectable/copyable by hand in that case. + } + } + + return ( +
+

Kalender synchronisieren

+

+ Abonniere deinen persönlichen Kalender in Apple Kalender, Google Kalender, Outlook, Thunderbird oder einer + anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken — teile ihn nicht. +

+ + {!url && ( +

+ {configured ? "Ein Kalender-Link ist eingerichtet." : "Es ist noch kein Kalender-Link eingerichtet."} +

+ )} + + {!url && ( + + )} + + {error &&

{error}

} + + {url && ( +
+

Wird nur jetzt einmal angezeigt — bitte gleich kopieren.

+
+ e.currentTarget.select()} /> + +
+ {copied &&

In die Zwischenablage kopiert.

} + +
+ )} + +

+ Apple Kalender: Ablage → Neues Kalenderabonnement → Link einfügen. +
+ Google Kalender: Weitere Kalender „+“ → Per URL → Link einfügen. +
+ Outlook: Kalender hinzufügen → Aus dem Internet abonnieren → Link einfügen. +
+ Thunderbird: Kalender → Neuer Kalender → Im Netzwerk → Link einfügen. +

+
+ ); +} diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx index a4d67da..74d2c08 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/LoginForm.tsx @@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { ResendVerificationButton } from "./ResendVerificationButton"; const fieldClass = "w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none"; @@ -67,6 +68,7 @@ export function LoginForm() { > {loading ? "Wird geprüft …" : "Anmelden"} + ); } diff --git a/components/auth/ResendVerificationButton.tsx b/components/auth/ResendVerificationButton.tsx new file mode 100644 index 0000000..0dbcddc --- /dev/null +++ b/components/auth/ResendVerificationButton.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState, type FormEvent } from "react"; + +const fieldClass = + "w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-2.5 text-sm text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none"; + +async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch("/api/auth/resend-verification", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(email ? { email } : {}), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return { ok: false, error: data.error || "Die Bestätigungs-E-Mail konnte nicht gesendet werden." }; + return { ok: true }; + } catch { + return { ok: false, error: "Verbindung fehlgeschlagen." }; + } +} + +const GENERIC_SENT_MESSAGE = + "Falls das Konto noch nicht bestätigt ist, wurde eine neue Bestätigungs-E-Mail gesendet."; + +/** + * variant "session": a single button that resends for the currently logged-in + * customer (used in /konto and the booking widget, where a session already + * exists). + * + * variant "email-prompt": a toggle link that reveals an email field first + * (used on /login, where the visitor may not be authenticated yet). + */ +export function ResendVerificationButton({ + variant, + triggerLabel = "E-Mail noch nicht bestätigt?", + className, +}: { + variant: "session" | "email-prompt"; + triggerLabel?: string; + className?: string; +}) { + const [open, setOpen] = useState(false); + const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle"); + const [error, setError] = useState(null); + + async function resendForSession() { + setStatus("sending"); + const result = await callResend(); + if (result.ok) { + setStatus("sent"); + } else { + setStatus("error"); + setError(result.error ?? null); + } + } + + async function handleEmailSubmit(e: FormEvent) { + e.preventDefault(); + setStatus("sending"); + setError(null); + const email = String(new FormData(e.currentTarget).get("email") ?? ""); + const result = await callResend(email); + if (result.ok) { + setStatus("sent"); + } else { + setStatus("error"); + setError(result.error ?? null); + } + } + + if (variant === "session") { + return ( +
+ {status === "sent" ? ( +

{GENERIC_SENT_MESSAGE}

+ ) : ( + + )} + {status === "error" && error && ( +

+ {error} +

+ )} +
+ ); + } + + return ( +
+ {!open ? ( + + ) : status === "sent" ? ( +

{GENERIC_SENT_MESSAGE}

+ ) : ( +
+ + +
+ )} + {status === "error" && error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/booking/BookingWidget.tsx b/components/booking/BookingWidget.tsx index 6be8a59..ed7e46a 100644 --- a/components/booking/BookingWidget.tsx +++ b/components/booking/BookingWidget.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { MonthCalendar, type CalendarMarker } from "./MonthCalendar"; +import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton"; type SlotsResponse = { durationMinutes: number; slots: Record }; @@ -16,7 +17,15 @@ function fmtDate(iso: string) { return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" }); } -export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; isLoggedIn: boolean }) { +export function BookingWidget({ + offerSlug, + isLoggedIn, + isVerified, +}: { + offerSlug: string; + isLoggedIn: boolean; + isVerified: boolean; +}) { const [slotsByDay, setSlotsByDay] = useState({}); const [loading, setLoading] = useState(true); const [selectedDay, setSelectedDay] = useState(null); @@ -76,6 +85,16 @@ export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; is ); } + if (!isVerified) { + return ( +
+

Bitte bestätige zuerst deine E-Mail-Adresse.

+

Erst danach kannst du einen Termin anfragen.

+ +
+ ); + } + const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" })); const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : []; diff --git a/fields/seo.ts b/fields/seo.ts new file mode 100644 index 0000000..1f92b8c --- /dev/null +++ b/fields/seo.ts @@ -0,0 +1,53 @@ +import type { Field } from "payload"; + +/** + * Reusable, always-optional SEO field group — added to every public-facing + * collection/global. Never required: every consumer falls back to a + * sensible, automatically derived value when a field is left empty (see + * lib/seo/resolve.ts), per the "SEO fields are optional with fallbacks" + * requirement. + */ +export function seoFields(): Field { + return { + type: "collapsible", + label: "SEO", + admin: { + description: "Optional — wird automatisch aus dem Inhalt abgeleitet, wenn hier nichts eingetragen ist.", + }, + fields: [ + { + name: "seo", + type: "group", + label: "", + fields: [ + { + name: "title", + type: "text", + label: "SEO Title", + maxLength: 70, + admin: { description: "Falls leer: automatisch aus dem Seitentitel erzeugt." }, + }, + { + name: "description", + type: "textarea", + label: "Meta Description", + maxLength: 300, + admin: { description: "Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen)." }, + }, + { + name: "keywords", + type: "text", + label: "SEO Keywords (optional, kommagetrennt)", + }, + { + name: "ogImage", + type: "upload", + relationTo: "media", + label: "Open Graph Bild", + admin: { description: "Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen." }, + }, + ], + }, + ], + }; +} diff --git a/globals/About.ts b/globals/About.ts index 4c3532b..10f3b65 100644 --- a/globals/About.ts +++ b/globals/About.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from "payload"; import { isAdmin } from "@/access"; +import { seoFields } from "@/fields/seo"; export const About: GlobalConfig = { slug: "about", @@ -26,5 +27,6 @@ export const About: GlobalConfig = { { name: "closingParagraph", type: "textarea", label: "Abschlusstext" }, ], }, + seoFields(), ], }; diff --git a/globals/AktuellesIntro.ts b/globals/AktuellesIntro.ts index 193e7bb..af1bfa4 100644 --- a/globals/AktuellesIntro.ts +++ b/globals/AktuellesIntro.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from "payload"; import { isAdmin } from "@/access"; +import { seoFields } from "@/fields/seo"; export const AktuellesIntro: GlobalConfig = { slug: "aktuelles-intro", @@ -16,5 +17,6 @@ export const AktuellesIntro: GlobalConfig = { { name: "eyebrow", type: "text", label: "Kicker" }, { name: "title", type: "text", label: "Überschrift", required: true }, { name: "lead", type: "textarea", label: "Einleitungstext" }, + seoFields(), ], }; diff --git a/globals/AngeboteIntro.ts b/globals/AngeboteIntro.ts index 15bcde2..3909272 100644 --- a/globals/AngeboteIntro.ts +++ b/globals/AngeboteIntro.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from "payload"; import { isAdmin } from "@/access"; +import { seoFields } from "@/fields/seo"; export const AngeboteIntro: GlobalConfig = { slug: "angebote-intro", @@ -16,5 +17,6 @@ export const AngeboteIntro: GlobalConfig = { { name: "eyebrow", type: "text", label: "Kicker" }, { name: "title", type: "text", label: "Überschrift", required: true }, { name: "lead", type: "textarea", label: "Einleitungstext" }, + seoFields(), ], }; diff --git a/globals/Booking.ts b/globals/Booking.ts index 9cfbc1c..8c50803 100644 --- a/globals/Booking.ts +++ b/globals/Booking.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from "payload"; import { isAdmin } from "@/access"; +import { seoFields } from "@/fields/seo"; export const Booking: GlobalConfig = { slug: "booking", @@ -28,5 +29,6 @@ export const Booking: GlobalConfig = { ], }, { name: "formNote", type: "textarea", label: "Hinweis unter dem Formular" }, + seoFields(), ], }; diff --git a/globals/BookingSettings.ts b/globals/BookingSettings.ts index 5ddffa6..7ce93be 100644 --- a/globals/BookingSettings.ts +++ b/globals/BookingSettings.ts @@ -15,7 +15,14 @@ export const BookingSettings: GlobalConfig = { fields: [ { name: "locationName", type: "text", label: "Name des Ortes", defaultValue: "Anouma" }, { name: "street", type: "text", label: "Straße und Hausnummer" }, - { name: "postalCode", type: "text", label: "PLZ" }, - { name: "city", type: "text", label: "Ort" }, + { + type: "row", + fields: [ + { name: "postalCode", type: "text", label: "PLZ", admin: { width: "34%" } }, + { name: "city", type: "text", label: "Ort", admin: { width: "33%" } }, + { name: "region", type: "text", label: "Region/Bundesland (optional)", admin: { width: "33%" } }, + ], + }, + { name: "country", type: "text", label: "Land (optional)", admin: { description: "Für strukturierte Daten, z. B. „Deutschland“." } }, ], }; diff --git a/globals/Contact.ts b/globals/Contact.ts index f0dfab0..1fbb0fe 100644 --- a/globals/Contact.ts +++ b/globals/Contact.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from "payload"; import { isAdmin } from "@/access"; +import { seoFields } from "@/fields/seo"; export const Contact: GlobalConfig = { slug: "contact", @@ -24,5 +25,6 @@ export const Contact: GlobalConfig = { { name: "region", type: "text", label: "Region / Ort", admin: { width: "33%" } }, ], }, + seoFields(), ], }; diff --git a/globals/Home.ts b/globals/Home.ts index 1525966..985145d 100644 --- a/globals/Home.ts +++ b/globals/Home.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from "payload"; import { isAdmin } from "@/access"; +import { seoFields } from "@/fields/seo"; export const Home: GlobalConfig = { slug: "home", @@ -66,5 +67,6 @@ export const Home: GlobalConfig = { }, ], }, + seoFields(), ], }; diff --git a/globals/SEOSettings.ts b/globals/SEOSettings.ts new file mode 100644 index 0000000..e6db6a7 --- /dev/null +++ b/globals/SEOSettings.ts @@ -0,0 +1,146 @@ +import type { GlobalConfig } from "payload"; +import { isAdmin } from "@/access"; + +const WEEKDAYS = [ + { label: "Montag", value: "Monday" }, + { label: "Dienstag", value: "Tuesday" }, + { label: "Mittwoch", value: "Wednesday" }, + { label: "Donnerstag", value: "Thursday" }, + { label: "Freitag", value: "Friday" }, + { label: "Samstag", value: "Saturday" }, + { label: "Sonntag", value: "Sunday" }, +] as const; + +/** + * Central SEO settings, edited once by Anna, applied automatically across + * the whole website (see lib/seo/*). Everything here is optional — nothing + * is invented if a field is left blank (see lib/seo/jsonld.ts, which only + * emits LocalBusiness structured data once real address data exists). + */ +export const SEOSettings: GlobalConfig = { + slug: "seo-settings", + label: "SEO-Einstellungen", + admin: { + description: "Globale SEO-Vorgaben für die gesamte Website — greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.", + group: "SEO", + }, + access: { + read: () => true, + update: isAdmin, + }, + fields: [ + { + type: "tabs", + tabs: [ + { + label: "Allgemein", + fields: [ + { + name: "siteUrl", + type: "text", + label: "Website-URL", + admin: { + description: "Nur bei Bedarf setzen (z. B. https://anouma.org) — überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.", + }, + validate: (value: string | null | undefined) => { + if (!value) return true; + return /^https?:\/\/.+/.test(value) || "Bitte eine vollständige URL angeben (z. B. https://anouma.org)."; + }, + }, + { + name: "defaultDescription", + type: "textarea", + label: "Standard-Beschreibung", + maxLength: 300, + admin: { description: "Wird verwendet, wenn eine Seite keine eigene Meta-Beschreibung hat." }, + }, + { + name: "defaultOgImage", + type: "upload", + relationTo: "media", + label: "Standard Social-Media-Bild", + admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat." }, + }, + { + name: "robotsIndexable", + type: "checkbox", + label: "Website für Suchmaschinen sichtbar", + defaultValue: true, + admin: { + description: "Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto — die sind ohnehin immer gesperrt.", + }, + }, + ], + }, + { + label: "Lokale Informationen", + admin: { description: "Werden nur verwendet, wenn tatsächlich ausgefüllt — es werden keine Angaben erfunden." }, + fields: [ + { + name: "businessName", + type: "text", + label: "Geschäfts-/Markenname", + defaultValue: "Anouma", + }, + { + name: "businessDescription", + type: "textarea", + label: "Kurzbeschreibung", + admin: { description: "Kurzer, sachlicher Beschreibungstext für strukturierte Daten (nicht der Website-Text)." }, + }, + { + type: "row", + fields: [ + { name: "latitude", type: "number", label: "Breitengrad (optional)", admin: { width: "50%" } }, + { name: "longitude", type: "number", label: "Längengrad (optional)", admin: { width: "50%" } }, + ], + }, + { + name: "openingHours", + type: "array", + label: "Öffnungs-/Erreichbarkeitszeiten", + admin: { description: "Optional — nur für tatsächlich feste Zeiten (z. B. Sprechzeiten)." }, + fields: [ + { + type: "row", + fields: [ + { + name: "days", + type: "select", + label: "Tage", + hasMany: true, + required: true, + options: [...WEEKDAYS], + admin: { width: "50%" }, + }, + { name: "opens", type: "text", label: "Von (z. B. 09:00)", required: true, admin: { width: "25%" } }, + { name: "closes", type: "text", label: "Bis (z. B. 17:00)", required: true, admin: { width: "25%" } }, + ], + }, + ], + }, + ], + }, + { + label: "Social Media", + fields: [ + { + name: "socialLinks", + type: "array", + label: "Profile", + fields: [ + { + type: "row", + fields: [ + { name: "platform", type: "text", label: "Plattform (z. B. Instagram)", required: true, admin: { width: "35%" } }, + { name: "url", type: "text", label: "Link", required: true, admin: { width: "65%" } }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +}; diff --git a/lib/auth/rateLimit.ts b/lib/auth/rateLimit.ts new file mode 100644 index 0000000..3dc9dcd --- /dev/null +++ b/lib/auth/rateLimit.ts @@ -0,0 +1,38 @@ +/** + * Minimal in-memory fixed-window rate limiter for a single Node process + * (this app always runs as one process — see server.ts). Good enough to + * blunt abuse of the email-verification resend endpoint without adding an + * external store; resets on deploy/restart, which is an acceptable + * trade-off for this use case. + */ +const buckets = new Map(); + +// Opportunistic cleanup so long-running processes don't accumulate an +// unbounded number of stale keys (one per distinct IP/email ever seen). +const MAX_TRACKED_KEYS = 5000; + +function sweepExpired(now: number) { + for (const [key, bucket] of buckets) { + if (bucket.resetAt < now) buckets.delete(key); + } +} + +export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean { + const now = Date.now(); + if (buckets.size > MAX_TRACKED_KEYS) sweepExpired(now); + + const bucket = buckets.get(key); + if (!bucket || bucket.resetAt < now) { + buckets.set(key, { count: 1, resetAt: now + opts.windowMs }); + return true; + } + if (bucket.count >= opts.max) return false; + bucket.count += 1; + return true; +} + +export function getClientIp(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) return forwarded.split(",")[0]!.trim(); + return "unknown"; +} diff --git a/lib/auth/verification.ts b/lib/auth/verification.ts new file mode 100644 index 0000000..9292782 --- /dev/null +++ b/lib/auth/verification.ts @@ -0,0 +1,22 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** How long a freshly issued email-verification link stays valid. */ +export const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000; + +/** 256 bits of randomness, hex-encoded — not guessable, never derived from user data. */ +export function generateVerificationToken(): string { + return randomBytes(32).toString("hex"); +} + +/** + * Only this hash is ever persisted (see collections/Customers.ts) — the + * plaintext token exists only in the URL sent by email and briefly in + * memory while that email is being sent. + */ +export function hashVerificationToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function verificationExpiryISO(): string { + return new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS).toISOString(); +} diff --git a/lib/auth/verifyEmailToken.ts b/lib/auth/verifyEmailToken.ts new file mode 100644 index 0000000..084dea7 --- /dev/null +++ b/lib/auth/verifyEmailToken.ts @@ -0,0 +1,38 @@ +import { getCMS } from "@/lib/payload/getPayload"; +import { hashVerificationToken } from "@/lib/auth/verification"; + +const TOKEN_SHAPE = /^[0-9a-f]{64}$/i; + +/** + * Verifies a token from a /auth/verify-email/[token] link: looks it up by + * hash (the plaintext is never stored), checks it hasn't expired, then + * marks the account verified and immediately clears the hash + expiry so + * the same link can never be used a second time. + */ +export async function verifyEmailToken(token: string): Promise { + if (!TOKEN_SHAPE.test(token)) return false; + + const payload = await getCMS(); + const hash = hashVerificationToken(token); + + const { docs } = await payload.find({ + collection: "customers", + where: { emailVerificationTokenHash: { equals: hash } }, + limit: 1, + }); + const customer = docs[0]; + if (!customer || !customer.emailVerificationExpires) return false; + if (new Date(customer.emailVerificationExpires).getTime() < Date.now()) return false; + + await payload.update({ + collection: "customers", + id: customer.id, + data: { + emailVerified: true, + emailVerificationTokenHash: null, + emailVerificationExpires: null, + }, + }); + + return true; +} diff --git a/lib/calendar/adminFeed.ts b/lib/calendar/adminFeed.ts new file mode 100644 index 0000000..941a658 --- /dev/null +++ b/lib/calendar/adminFeed.ts @@ -0,0 +1,109 @@ +import type { Payload } from "payload"; +import { EVENT_CATEGORIES } from "@/collections/Events"; +import type { ICSEvent } from "./ics"; +import type { BookingRequest, Customer, Event, Offer } from "@/payload-types"; + +// A rolling window rather than "everything ever": calendar apps refetch a +// subscribed feed periodically on their own, so this just needs to cover a +// sensible range each time, not paginate an entire history. +const WINDOW_PAST_MS = 30 * 24 * 60 * 60 * 1000; +const WINDOW_FUTURE_MS = 400 * 24 * 60 * 60 * 1000; + +/** + * Builds the calendar entries for Anna's personal ICS feed: confirmed + * bookings (never pending ones) plus standalone public events — onsite and + * online alike. Deliberately never includes the meeting password anywhere + * in the output, only a link to the protected join page. + * + * Local API is used with the default overrideAccess: true because this is + * only ever reached after the caller already verified the feed's secret + * token (see app/(frontend)/calendar/[tokenFile]/route.ts) — equivalent + * trust to the existing admin calendar view. + */ +export async function getAdminFeedICSEvents(payload: Payload): Promise { + const from = new Date(Date.now() - WINDOW_PAST_MS); + const to = new Date(Date.now() + WINDOW_FUTURE_MS); + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000"; + const domainHost = new URL(serverUrl).host; + + const [{ docs: bookings }, { docs: events }, settings] = await Promise.all([ + payload.find({ + collection: "booking-requests", + where: { + and: [ + { status: { equals: "confirmed" } }, + { date: { greater_than_equal: from.toISOString() } }, + { date: { less_than_equal: to.toISOString() } }, + ], + }, + depth: 2, + limit: 1000, + }), + payload.find({ + collection: "events", + where: { + and: [ + { date: { greater_than_equal: from.toISOString() } }, + { date: { less_than_equal: to.toISOString() } }, + { isPrivateBooking: { not_equals: true } }, + ], + }, + depth: 0, + limit: 1000, + }), + payload.findGlobal({ slug: "booking-settings" }), + ]); + + const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean); + const fullAddress = addressParts.join(", "); + + const bookingEvents: ICSEvent[] = (bookings as BookingRequest[]).map((b) => { + const offer = typeof b.offer === "object" ? (b.offer as Offer) : null; + const customer = typeof b.user === "object" ? (b.user as Customer) : null; + const isOnline = b.appointmentType === "online"; + + const description = [ + customer?.name ? `Mit: ${customer.name}` : null, + isOnline ? "Online-Termin" : "Vor-Ort-Termin", + b.userMessage ? `Nachricht: ${b.userMessage}` : null, + ] + .filter(Boolean) + .join("\n"); + + let url: string | undefined; + if (isOnline && b.linkedEvent && typeof b.linkedEvent === "object") { + url = `${serverUrl}/termine/${(b.linkedEvent as Event).slug}/beitreten`; + } else if (!isOnline && fullAddress) { + url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`; + } + + return { + uid: `booking-${b.id}@${domainHost}`, + start: new Date(b.startTime), + end: new Date(b.endTime), + summary: offer?.title ?? "Termin", + description: description || undefined, + // Vor Ort: vollständige Adresse. Online: literal "Online" — never the + // meeting password, which is intentionally not part of this feed. + location: isOnline ? "Online" : fullAddress || undefined, + url, + }; + }); + + const eventEvents: ICSEvent[] = (events as Event[]).map((e) => { + const isOnline = Boolean(e.isOnline); + const start = new Date(e.startTime || e.date); + const end = new Date(e.endTime || e.startTime || e.date); + return { + uid: `event-${e.id}@${domainHost}`, + start, + end, + summary: e.title, + description: EVENT_CATEGORIES.find((c) => c.value === e.category)?.label, + location: isOnline ? "Online" : e.location || fullAddress || undefined, + url: e.slug ? `${serverUrl}/termine/${e.slug}` : undefined, + }; + }); + + return [...bookingEvents, ...eventEvents]; +} diff --git a/lib/calendar/feedToken.ts b/lib/calendar/feedToken.ts new file mode 100644 index 0000000..f3cdfb3 --- /dev/null +++ b/lib/calendar/feedToken.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** + * Long-lived, random, rotatable secret for a personal calendar-subscription + * URL (/calendar/.ics) — treated like an API key, not a + * short-lived link: only its hash is stored, so a database leak alone + * can't be used to subscribe to anyone's calendar, and rotating it + * (overwriting the stored hash) immediately invalidates the old URL. + */ +export function generateCalendarFeedToken(): string { + return randomBytes(32).toString("hex"); +} + +export function hashCalendarFeedToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function calendarFeedUrl(token: string): string { + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000"; + return `${serverUrl}/calendar/${token}.ics`; +} diff --git a/lib/calendar/ics.ts b/lib/calendar/ics.ts new file mode 100644 index 0000000..78c194a --- /dev/null +++ b/lib/calendar/ics.ts @@ -0,0 +1,72 @@ +import { BERLIN_VTIMEZONE_LINES, toBerlinICSDateTime, toUTCICSStamp } from "./timezone"; + +export type ICSEvent = { + uid: string; + start: Date; + end: Date; + summary: string; + description?: string; + location?: string; + url?: string; +}; + +function escapeICSText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\n/g, "\\n"); +} + +/** RFC 5545 line folding: lines must not exceed 75 octets; continuations start with a single space. */ +function foldLine(line: string): string { + const encoder = new TextEncoder(); + if (encoder.encode(line).length <= 75) return line; + + const chunks: string[] = []; + let current = ""; + for (const char of line) { + const candidate = current + char; + if (encoder.encode(candidate).length > 75) { + chunks.push(current); + current = char; + } else { + current = candidate; + } + } + if (current) chunks.push(current); + return chunks.join("\r\n "); +} + +/** Builds a complete RFC 5545 VCALENDAR document, Europe/Berlin timezone-aware throughout. */ +export function buildICSCalendar(args: { calendarName: string; events: ICSEvent[] }): string { + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//ANOUMA//Calendar Feed//DE", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + `X-WR-CALNAME:${escapeICSText(args.calendarName)}`, + "X-WR-TIMEZONE:Europe/Berlin", + ...BERLIN_VTIMEZONE_LINES, + ]; + + const stamp = toUTCICSStamp(new Date()); + for (const ev of args.events) { + lines.push( + "BEGIN:VEVENT", + `UID:${ev.uid}`, + `DTSTAMP:${stamp}`, + `DTSTART;TZID=Europe/Berlin:${toBerlinICSDateTime(ev.start)}`, + `DTEND;TZID=Europe/Berlin:${toBerlinICSDateTime(ev.end)}`, + `SUMMARY:${escapeICSText(ev.summary)}`, + ); + if (ev.description) lines.push(`DESCRIPTION:${escapeICSText(ev.description)}`); + if (ev.location) lines.push(`LOCATION:${escapeICSText(ev.location)}`); + if (ev.url) lines.push(`URL:${ev.url}`); + lines.push("STATUS:CONFIRMED", "END:VEVENT"); + } + + lines.push("END:VCALENDAR"); + return lines.map(foldLine).join("\r\n") + "\r\n"; +} diff --git a/lib/calendar/timezone.ts b/lib/calendar/timezone.ts new file mode 100644 index 0000000..d0160bb --- /dev/null +++ b/lib/calendar/timezone.ts @@ -0,0 +1,57 @@ +const BERLIN_TZ = "Europe/Berlin"; + +/** + * Converts a JS Date (a UTC instant) into its Europe/Berlin wall-clock + * representation for iCalendar's local `TZID` date-time form + * (`DTSTART;TZID=Europe/Berlin:YYYYMMDDTHHMMSS`). Uses the ICU timezone + * database via Intl rather than manual UTC+1/+2 arithmetic, so daylight + * saving transitions are always handled correctly — no naive UTC offset + * math that would drift by an hour half the year. + */ +export function toBerlinICSDateTime(date: Date): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: BERLIN_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00"; + return `${get("year")}${get("month")}${get("day")}T${get("hour")}${get("minute")}${get("second")}`; +} + +/** UTC timestamp form (`YYYYMMDDTHHMMSSZ`) — used only for DTSTAMP, which marks generation time, not a local event time. */ +export function toUTCICSStamp(date: Date): string { + return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; +} + +/** + * Standard IANA Europe/Berlin VTIMEZONE block (the same one commonly + * embedded by calendar exporters): CEST from the last Sunday in March, + * CET from the last Sunday in October — the actual EU DST rule, not an + * approximation. + */ +export const BERLIN_VTIMEZONE_LINES = [ + "BEGIN:VTIMEZONE", + "TZID:Europe/Berlin", + "X-LIC-LOCATION:Europe/Berlin", + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:+0100", + "TZOFFSETTO:+0200", + "TZNAME:CEST", + "DTSTART:19700329T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:+0200", + "TZOFFSETTO:+0100", + "TZNAME:CET", + "DTSTART:19701025T030000", + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", + "END:STANDARD", + "END:VTIMEZONE", +]; diff --git a/lib/email/authTemplates.ts b/lib/email/authTemplates.ts new file mode 100644 index 0000000..cce35e5 --- /dev/null +++ b/lib/email/authTemplates.ts @@ -0,0 +1,14 @@ +import { paragraph, renderButton, renderEmailShell } from "./layout"; + +export function verificationEmail(args: { name: string; verifyUrl: string; expiresHours: number }): { subject: string; html: string } { + const html = renderEmailShell({ + bodyHtml: [ + paragraph(`Hallo ${args.name},`), + paragraph("dein Konto wurde erstellt.", { italic: true }), + paragraph("Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren."), + renderButton("E-Mail-Adresse bestätigen", args.verifyUrl), + paragraph(`Dieser Link ist ${args.expiresHours} Stunden gültig. Falls du kein Konto erstellt hast, kannst du diese E-Mail ignorieren.`, { small: true }), + ].join("\n"), + }); + return { subject: "Bitte bestätige deine E-Mail-Adresse", html }; +} diff --git a/lib/email/bookingTemplates.ts b/lib/email/bookingTemplates.ts index 5295eee..6a61be1 100644 --- a/lib/email/bookingTemplates.ts +++ b/lib/email/bookingTemplates.ts @@ -98,7 +98,7 @@ export function bookingRejectedEmail(args: BaseArgs): { subject: string; html: s { label: "Datum", value: args.dateLabel }, { label: "Uhrzeit", value: args.timeLabel }, ]), - paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem ANOUMA-Konto vorbei.", { small: true }), + paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem Konto vorbei.", { small: true }), ].join("\n"), }); return { subject: "Deine Terminanfrage bei ANOUMA", html }; diff --git a/lib/payload/globals.ts b/lib/payload/globals.ts index 03746e2..40eed55 100644 --- a/lib/payload/globals.ts +++ b/lib/payload/globals.ts @@ -1,6 +1,6 @@ import { cache } from "react"; import { getCMS } from "@/lib/payload/getPayload"; -import type { About, AktuellesIntro, AngeboteIntro, Booking, Contact, Home } from "@/payload-types"; +import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, Home, SeoSetting } from "@/payload-types"; export const getHomeGlobal = cache(async (): Promise => { const payload = await getCMS(); @@ -31,3 +31,13 @@ export const getBookingGlobal = cache(async (): Promise => { const payload = await getCMS(); return payload.findGlobal({ slug: "booking" }); }); + +export const getBookingSettingsGlobal = cache(async (): Promise => { + const payload = await getCMS(); + return payload.findGlobal({ slug: "booking-settings" }); +}); + +export const getSEOSettingsGlobal = cache(async (): Promise => { + const payload = await getCMS(); + return payload.findGlobal({ slug: "seo-settings" }); +}); diff --git a/lib/seo/config.ts b/lib/seo/config.ts new file mode 100644 index 0000000..aaf7edc --- /dev/null +++ b/lib/seo/config.ts @@ -0,0 +1,36 @@ +import { getSEOSettingsGlobal } from "@/lib/payload/globals"; +import { siteConfig } from "@/lib/site"; + +function stripTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +/** + * The canonical base URL for the whole site. Prefers the CMS override + * (SEO-Einstellungen → Website-URL) when it's a valid absolute URL, then + * NEXT_PUBLIC_SERVER_URL (the single source of truth used everywhere else + * in this app — emails, meeting links, Payload's own serverURL), then the + * hardcoded siteConfig fallback. Never throws — SEO metadata must never + * break a page render. + */ +export async function getSiteUrl(): Promise { + try { + const settings = await getSEOSettingsGlobal(); + if (settings.siteUrl && /^https?:\/\/.+/.test(settings.siteUrl)) { + return stripTrailingSlash(settings.siteUrl); + } + } catch { + // CMS/DB unreachable — fall through to the env-based default. + } + return stripTrailingSlash(process.env.NEXT_PUBLIC_SERVER_URL || siteConfig.domain); +} + +/** Whether the site should be indexable at all (global kill switch in the CMS). */ +export async function isSiteIndexable(): Promise { + try { + const settings = await getSEOSettingsGlobal(); + return settings.robotsIndexable !== false; + } catch { + return true; + } +} diff --git a/lib/seo/dates.ts b/lib/seo/dates.ts new file mode 100644 index 0000000..dae9dd3 --- /dev/null +++ b/lib/seo/dates.ts @@ -0,0 +1,29 @@ +/** + * Formats a JS Date as an ISO 8601 string carrying its actual Europe/Berlin + * UTC offset (e.g. "2026-06-15T09:00:00+02:00" in summer, + * "...+01:00" in winter) — what schema.org's Event startDate/endDate + * expect. Uses Intl's timezone database rather than hardcoded +1/+2 math, + * so it's correct across the DST transition automatically. + */ +export function toBerlinOffsetISOString(date: Date): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZoneName: "shortOffset", + }).formatToParts(date); + + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00"; + const tzName = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+0"; + const match = /GMT([+-])(\d+)(?::(\d+))?/.exec(tzName); + const sign = match?.[1] ?? "+"; + const offsetHours = (match?.[2] ?? "0").padStart(2, "0"); + const offsetMinutes = (match?.[3] ?? "0").padStart(2, "0"); + + return `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}${sign}${offsetHours}:${offsetMinutes}`; +} diff --git a/lib/seo/jsonld.tsx b/lib/seo/jsonld.tsx new file mode 100644 index 0000000..37eeb0c --- /dev/null +++ b/lib/seo/jsonld.tsx @@ -0,0 +1,231 @@ +import { siteConfig } from "@/lib/site"; +import { toBerlinOffsetISOString } from "./dates"; +import type { BookingSetting, Contact, Event, Offer, Post, SeoSetting } from "@/payload-types"; + +// Server-only builders — every function here returns `null` instead of +// guessing when the real data needed for a valid/honest result isn't +// present. Nothing here ever invents a review, price, address or phone +// number (see section 27/35 of the SEO brief). + +/** JSON.stringify with `<` escaped so this can never be broken out of by any (currently non-existent) embedded "" sequence. */ +function toSafeJsonLdString(data: unknown): string { + return JSON.stringify(data).replace(/; + +export function organizationName(settings: SeoSetting | null | undefined): string { + return settings?.businessName?.trim() || siteConfig.name; +} + +function hasFullAddress(booking: BookingSetting | null | undefined): booking is BookingSetting & { street: string; city: string; postalCode: string } { + return Boolean(booking?.street?.trim() && booking?.city?.trim() && booking?.postalCode?.trim()); +} + +function postalAddress(booking: BookingSetting) { + return { + "@type": "PostalAddress", + streetAddress: booking.street!.trim(), + postalCode: booking.postalCode!.trim(), + addressLocality: booking.city!.trim(), + ...(booking.region?.trim() ? { addressRegion: booking.region.trim() } : {}), + ...(booking.country?.trim() ? { addressCountry: booking.country.trim() } : {}), + }; +} + +/** + * Organization (always safe — just a name and a URL) upgraded to + * ProfessionalService/LocalBusiness once a real, complete street address + * has actually been entered in the CMS. Phone/opening hours/geo are added + * only when present; nothing is fabricated to "complete" the schema. + */ +export function organizationOrLocalBusinessJsonLd(args: { + settings: SeoSetting | null | undefined; + booking: BookingSetting | null | undefined; + contact: Contact | null | undefined; + siteUrl: string; +}): JsonLdValue { + const name = organizationName(args.settings); + const base: JsonLdValue = { + "@context": "https://schema.org", + name, + url: args.siteUrl, + }; + if (args.settings?.businessDescription?.trim()) base.description = args.settings.businessDescription.trim(); + if (args.contact?.email) base.email = args.contact.email; + + if (!hasFullAddress(args.booking)) { + return { ...base, "@type": "Organization" }; + } + + const result: JsonLdValue = { + ...base, + "@type": "ProfessionalService", + address: postalAddress(args.booking), + }; + + if (args.contact?.phone?.trim()) result.telephone = args.contact.phone.trim(); + if (typeof args.settings?.latitude === "number" && typeof args.settings?.longitude === "number") { + result.geo = { "@type": "GeoCoordinates", latitude: args.settings.latitude, longitude: args.settings.longitude }; + } + if (args.settings?.openingHours?.length) { + result.openingHoursSpecification = args.settings.openingHours.map((entry) => ({ + "@type": "OpeningHoursSpecification", + dayOfWeek: entry.days.map((day) => `https://schema.org/${day}`), + opens: entry.opens, + closes: entry.closes, + })); + } + if (args.settings?.socialLinks?.length) { + result.sameAs = args.settings.socialLinks.map((link) => link.url).filter(Boolean); + } + + return result; +} + +export function websiteJsonLd(siteUrl: string): JsonLdValue { + return { + "@context": "https://schema.org", + "@type": "WebSite", + name: siteConfig.name, + url: siteUrl, + }; +} + +export function webPageJsonLd(args: { name: string; description: string; url: string }): JsonLdValue { + return { + "@context": "https://schema.org", + "@type": "WebPage", + name: args.name, + description: args.description, + url: args.url, + }; +} + +export function breadcrumbJsonLd(items: { name: string; url?: string }[]): JsonLdValue | null { + if (items.length === 0) return null; + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + // The last item's "item" URL is deliberately omitted when absent — + // that's the current page, and per Google's guidelines a BreadcrumbList + // entry doesn't need a URL for the page the visitor is already on. + itemListElement: items.map((item, index) => ({ + "@type": "ListItem", + position: index + 1, + name: item.name, + ...(item.url ? { item: item.url } : {}), + })), + }; +} + +export function serviceJsonLd(args: { + offer: Offer; + description: string; + imageUrl?: string; + siteUrl: string; + providerName: string; + areaServed?: string; +}): JsonLdValue { + const result: JsonLdValue = { + "@context": "https://schema.org", + "@type": "Service", + name: args.offer.title, + description: args.description, + url: `${args.siteUrl}/angebote/${args.offer.slug}`, + provider: { "@type": "Organization", name: args.providerName, url: args.siteUrl }, + }; + if (args.imageUrl) result.image = args.imageUrl; + if (args.areaServed) result.areaServed = args.areaServed; + // Deliberately no "offers"/price — the CMS "price" field is free text + // (e.g. "auf Anfrage") and can't be turned into a valid structured price + // without risking incorrect data. + return result; +} + +export function eventJsonLd(args: { + event: Event; + description: string; + imageUrl?: string; + joinUrl?: string; + siteUrl: string; + providerName: string; + booking: BookingSetting | null | undefined; +}): JsonLdValue | null { + const { event } = args; + // Private single-session bookings must never surface as a public Event + // (see section 18) — defense in depth alongside the page-level checks + // that already keep them out of public listings entirely. + if (event.isPrivateBooking) return null; + if (!event.date) return null; + + const start = new Date(event.startTime || event.date); + const isOnline = Boolean(event.isOnline); + + const result: JsonLdValue = { + "@context": "https://schema.org", + "@type": "Event", + name: event.title, + description: args.description, + startDate: toBerlinOffsetISOString(start), + eventStatus: "https://schema.org/EventScheduled", + eventAttendanceMode: isOnline ? "https://schema.org/OnlineEventAttendanceMode" : "https://schema.org/OfflineEventAttendanceMode", + url: `${args.siteUrl}/termine/${event.slug}`, + organizer: { "@type": "Organization", name: args.providerName, url: args.siteUrl }, + }; + + if (event.endTime) result.endDate = toBerlinOffsetISOString(new Date(event.endTime)); + if (args.imageUrl) result.image = args.imageUrl; + + if (isOnline) { + result.location = { "@type": "VirtualLocation", url: args.joinUrl ?? result.url }; + } else if (hasFullAddress(args.booking)) { + result.location = { + "@type": "Place", + name: event.location || args.booking.locationName || args.providerName, + address: postalAddress(args.booking), + }; + } else if (event.location) { + // No structured address on file, but the event itself names a place — + // still valid schema.org (address is optional on Place). + result.location = { "@type": "Place", name: event.location }; + } + + return result; +} + +export function articleJsonLd(args: { + post: Post; + description: string; + imageUrl?: string; + siteUrl: string; + providerName: string; +}): JsonLdValue { + const url = `${args.siteUrl}/aktuelles/${args.post.slug}`; + return { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: args.post.title, + description: args.description, + url, + mainEntityOfPage: url, + datePublished: new Date(args.post.publishDate ?? args.post.createdAt).toISOString(), + dateModified: new Date(args.post.updatedAt).toISOString(), + author: { "@type": "Person", name: args.post.author?.trim() || args.providerName }, + publisher: { "@type": "Organization", name: args.providerName, url: args.siteUrl }, + ...(args.imageUrl ? { image: args.imageUrl } : {}), + }; +} + +/** Renders one or more JSON-LD objects as server-side