feat: redesign CMS and unify German admin UI

This commit is contained in:
2026-09-17 16:51:24 +02:00
parent a2b3e99174
commit 7ec0c51ff5
129 changed files with 952 additions and 550 deletions
+3 -3
View File
@@ -9,8 +9,8 @@ function stripTrailingSlash(url: string): string {
* 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
* 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<string> {
@@ -20,7 +20,7 @@ export async function getSiteUrl(): Promise<string> {
return stripTrailingSlash(settings.siteUrl);
}
} catch {
// CMS/DB unreachable — fall through to the env-based default.
// CMS/DB unreachable - fall through to the env-based default.
}
return stripTrailingSlash(process.env.NEXT_PUBLIC_SERVER_URL || siteConfig.domain);
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 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
* "...+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.
*/
+6 -6
View File
@@ -2,7 +2,7 @@ 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
// 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).
@@ -34,7 +34,7 @@ function postalAddress(booking: BookingSetting) {
}
/**
* Organization (always safe — just a name and a URL) upgraded to
* 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.
@@ -107,7 +107,7 @@ export function breadcrumbJsonLd(items: { name: string; url?: string }[]): JsonL
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
// The last item's "item" URL is deliberately omitted when absent —
// 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) => ({
@@ -137,7 +137,7 @@ export function serviceJsonLd(args: {
};
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
// 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;
@@ -154,7 +154,7 @@ export function eventJsonLd(args: {
}): 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
// (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;
@@ -186,7 +186,7 @@ export function eventJsonLd(args: {
address: postalAddress(args.booking),
};
} else if (event.location) {
// No structured address on file, but the event itself names a place —
// 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 };
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { getSiteUrl, isSiteIndexable } from "./config";
/**
* Single shared Metadata builder used by every public page's
* generateMetadata — guarantees every page gets its own canonical, robots,
* generateMetadata - guarantees every page gets its own canonical, robots,
* Open Graph and Twitter/X card data instead of copy-pasted boilerplate
* (and the accidental duplicate-description bugs that come with that).
*
+1 -1
View File
@@ -4,7 +4,7 @@ import { getCMS } from "@/lib/payload/getPayload";
/**
* Looks up a CMS-configured redirect for a changed slug (see
* collections/Redirects.ts). Only called from the three dynamic [slug]
* detail pages right before they'd otherwise 404 — not a catch-all
* detail pages right before they'd otherwise 404 - not a catch-all
* middleware, so this never adds a database lookup to the other ~30 routes
* that never need it.
*/