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