Public Center-Website API — Vertrag (Read)
Dieses Dokument ist der verbindliche Referenz-Vertrag für öffentliche Lesezugriffe auf Center-Daten über das CockpitOS-Dashboard (Next.js API Routes). Es dient:
- als Spezifikation für Backend (P0/P1),
- als Kontext für v0 / andere Frontends (eigenständige Apps per
fetch— nicht der ZIP-Workflow für Website-Templates im Cockpit; siehe Templates — Intro), - als Grundlage für Dogfooding (
server-data-loaderschrittweise per HTTP): u. a. Page Content, Services, Shop-Kategorien (GET …/website-categories, gleiche Logik wie früher/api/categories), Category-Themes (GET …/category-themes-for-website— Website-Payload, nicht das rohe Admin-JSON von…/category-themes), Baustellen-Tagebuch, Einzel-Angebot, Aktuelles, Hot Picks (GET …/hotpicks), Shops, Office-Themes, Büros, Centerplan, DOOH-Playlists (öffentliche Leserouten unter…/dooh/public/…für v0/Vercel). Gemeinsame Implementierung:@mall-os/database(getWebsiteShopCategories,getWebsiteCategoryThemesPayload). Die Center-Website-/api/categoriesund/api/category-themesrufen dieselben Helper auf (BFF für Browser)./api/centers/centerplanbleibt BFF für Slug→ID und kombinierte Antwort.
Maschinenlesbar (OpenAPI, optional): /openapi/public-wayfinding-read.yaml (öffentliche Routen GET …/wayfinding/floors und …/centerplan). Für AgencyOS inkl. GET …/context siehe /openapi/agencyos-integration.yaml.
Siehe auch: Lückenanalyse & Prioritäten (CORS-Status, fehlende Routen). Redaktion — Anleitung A–Z (Lesen vs. Schreiben, v0, IT-Übergabe): v0-Website A–Z (für Redaktion). Redaktion — nur Schritte & Instructions D/A/B/C/E (10×5000-Zeichen-Limit): v0 + Cockpit (So geht’s). Schritt-für-Schritt (auch ohne Dev-Hintergrund): v0 mit öffentlicher API. Beispiel-JSON, Medien-URLs (resolveMediaUrl), Typ-Hinweise: Beispiele & Medien.
Basis
| Feld | Wert |
|---|---|
| Produktions-Basis-URL | https://dashboard.cockpit-os.de |
| Konfiguration im Code | getDashboardApiUrl() in packages/dashboard-api — Env: DASHBOARD_API_URL, NEXT_PUBLIC_DASHBOARD_URL oder NEXT_PUBLIC_API_URL |
| Auth (dieser Vertrag) | Keine Session, keine API-Keys — nur öffentlich zulässige Lesedaten. Schreibzugriffe und website-config GET sind nicht Teil dieses Vertrags. |
| Content-Type | application/json |
| Fehler | Typisch { success: false, error: string } oder { error: string } mit passendem HTTP-Status (400, 404, 429, 500). Exakte Felder können je Route leicht variieren. |
Status je Endpunkt
- Bereit + CORS * — Browser von anderer Origin (z. B. Vercel) kann direkt
fetchen (inkl.OPTIONS-Preflight). - Geplant (P1) — noch nicht oder nur teilweise; Vertrag beschreibt Ziel.
Sicherheit & Datenabgrenzung
- Nicht jede Dashboard-API ist öffentlich: Endpunkte unter
/api/dashboard/…, Session-Routen,website-configGET, Schreiboperationen ohne harte Auth bleiben nicht für anonyme Cross-Origin-Clients gedacht. public-visitor-surfaceliefert nur eine Whitelist (keine Secrets, kein vollesthemeOverrides). Erweiterungen nur nach kurzem Privacy-/Security-Check.page-contentPOST bleibt ohne breites CORS — öffentliche Schreibzugriffe ohne Token sind unerwünscht.- Chatbot & Routing POST:
POST /api/ai/visitor-chatbotundPOST /api/wayfinding/routingsind Redis-basiert pro Client-IP begrenzt (Zeitfenster überrate-limit.ts); Antwort 429 inkl. CORS*und HeaderRetry-After,X-RateLimit-*. Bei Redis-Ausfall fail-open. Tests:COCKPIT_DISABLE_PUBLIC_VISITOR_RATE_LIMIT=1. Zusätzlich WAF bleibt optional (P2).
Ablauf: centerId ermitteln
Externe Apps kennen oft den Slug oder die Custom Domain, nicht die UUID.
1) Nach Slug
GET /api/centers/by-slug/{slug}
| CORS | Ja (*) |
| Antwort | Objekt mit u. a. id, name, slug, city, address, baseColor, logo, urls |
Beispiel (gekürzt):
{
"id": "34eca9c3-1ea7-4c5a-b83a-d2b6bfb0c9f2",
"name": "Beispiel Center",
"slug": "beispiel-center",
"city": "Berlin",
"address": "Musterstraße 1",
"baseColor": "#3b82f6",
"secondaryColor": "#64748b",
"logo": "https://…",
"logoUrl": "https://…",
"coverImageUrl": null,
"websiteFavicon": null,
"theme": "light",
"organization": null,
"urls": {
"dashboard": "https://dashboard.cockpit-os.de/centers/34eca9c3-…",
"manager": "https://beispiel-center.manager.cockpit-os.de",
"signage": "https://beispiel-center.signage.cockpit-os.de",
"companion": "https://beispiel-center.signage.cockpit-os.de/companion",
"main": "https://preview.cockpit-os.de/beispiel-center"
}
}
2) Nach Custom Domain
GET /api/centers/by-domain?domain={host}
| Parameter | domain — Hostname ohne Pfad, z. B. www.palais-vest.de oder palais-vest.de |
| CORS | Ja (*) |
Beispiel:
{
"success": true,
"center": {
"id": "…",
"name": "…",
"slug": "…",
"customDomain": "palais-vest.de",
"customDomains": ["palais-vest.de", "www.palais-vest.de"],
"websiteEnabled": true,
"domainVerified": true,
"baseColor": "#…",
"secondaryColor": "#…",
"logoUrl": "https://…",
"organizationId": "…",
"comingSoonEnabled": false
}
}
Theme & Website-Konfiguration (öffentlich)
Theme-Konfiguration (SPA / Template)
GET /api/centers/by-slug/{slug}/theme-config
| CORS | Ja (*) |
| Antwort | Objekt aus SpaThemeManager.getSpaThemeConfig(centerId) — Struktur center- und template-abhängig (Theme-Plugin, templateContent, Farben, Feature-Flags). Für stabile Felder: Live-Response eines Centers loggen und im Frontend defensiv parsen. |
Hinweis: Vollständiges GET /api/centers/{centerId}/website-config ist auth-pflichtig und kein Teil dieses Vertrags.
Öffentliches Besucher-Bundle (Whitelist)
GET /api/centers/{centerId}/public-visitor-surface
| CORS | Ja (*) |
| Voraussetzung | Center existiert (centerId). Kein websiteEnabled-Zwang (Bundle ist feldsicher; Client kann Freigabe selbst steuern). |
| Antwort | { success: true, data: { schemaVersion: 1, center, seo, chatbot, centerplan, wayfindingMap, pagesConfig, features, tracking, visitorPrivacy, **templatePublicContent**, **v0Integration**, apiHints } } |
-
templatePublicContent:{ templateId, rootKey, content }— besucher-sichere Teilmenge vonthemeOverrides.templateContent.{rootKey}(alle Website-Templates: RGW, ILG, MEC, Goldbeck, …). Enthält u. a. Hero-Slides, Footer-Optionen,pageVisibility. Ausgeschlossen:formRecipients, Passwörter, API-Keys. v0/Claude: nieGET …/website-configohne Auth (401) — stattdessen dieses Feld. -
v0Integration: Kurzreferenz Lesen (öffentlich) vs. Schreiben (MCP/AgencyOS) — identisch zucockpit_agencyos_website_config_schema→v0Integration. -
apiHints.pageContentGet/homepageTilesGet: relative Pfade zu öffentlichen CMS-Seiten bzw. Startseiten-Kacheln. -
visitorPrivacy: gebündelte Felder für Cookie-Banner-Texte (austhemeOverrides), Chatbot-Consent und Datenschutz-Link (ausAIConfiguration.parameters), plus relative Pfade/datenschutzund/impressum— gleiche Quelle wie Dashboard „Datenschutz & Besucher-Consent“ undGET /api/wordpress/embed-config. -
chatbot: nur UI-Felder (Farben, Texte, Consent-Links) — keineapiKey/provider/model. -
apiHints: relative Pfade zu Visitor-Chatbot, Wayfinding und DOOH (öffentlich) — Basis-URL = gleicher Host wie API (siehe Abschnitt DOOH). -
tracking: typische Analytics-IDs (wie sie ohnehin oft im Client landen); nieanalyticsPagePassword.
DOOH (Digital Out-of-Home, öffentlich für v0)
Anlegen und Pflege von Playlists und Medien nur im Cockpit: Digital Experience → DOOH (nicht über diese öffentlichen Routen).
Voraussetzung für alle Routen in diesem Abschnitt: websiteEnabled === true für das Center — sonst 404 (Center not found or website disabled). CORS: * inkl. OPTIONS.
| Methode | Pfad | Zweck |
|---|---|---|
| GET | /api/centers/{centerId}/dooh/public/playlists | Alle Playlists des Centers ohne Item-Payloads: slug, name, itemCount, Gültigkeit, currentlyBroadcasting (entspricht aktiv + jetzt im Datumsfenster). |
| GET | /api/centers/{centerId}/dooh/public/playlist?slug={slug} | Eine Playlist mit Items zum Abspielen — nur wenn isActive und validFrom/validUntil den jetzigen Zeitpunkt abdecken (wie Ausspielung am Kiosk). Query slug Pflicht (Zeichen: Buchstaben, Ziffern, -, _, Länge 1–63). |
| GET | /api/centers/{centerId}/dooh/public/active | Kurzform für Slug idle (Idle-Werbeslot). Antwort: { success, playlist }. |
| GET | /api/centers/{centerId}/dooh/public/local-hero | Local-Hero-Karten (aufgelöste Namen/Bilder): { success, items }. |
Playlist-Items: type typischerweise VIDEO | IMAGE | SLIDESHOW | IFRAME; payload JSON mit URL(s) wie im Cockpit; durationSeconds (bei 0 bei Video oft „ganze Länge“ im Client). Medien-URLs ggf. wie unter Beispiele & Medien mit CDN-Basis auflösen.
Hinweis Digital Signage: GET /api/centers/{centerId}/dooh/active (ohne public) bleibt für Kiosk/Signage ohne websiteEnabled-Zwang; für externe Websites / v0 die …/dooh/public/…-Routen nutzen.
Shops
GET /api/centers/{centerId}/shops
| Query | Default | Beschreibung |
|---|---|---|
limit | 100 | Seitengröße (max. 5000 pro Request) |
offset | 0 | Offset |
publicWebsite / websitePublic | — | true = gleiche Filter wie Center-Website-SSR: status (Default Aktiv) + publishStartDate/publishEndDate auf Shop, Filiale und Kette |
status | bei publicWebsite: Aktiv | Nur in Kombination mit publicWebsite maßgeblich; ohne publicWebsite weiterhin „nicht Inaktiv“ |
includeWayfindingLinkages | — | true = pro Shop/Filiale auf dieser Seite (limit/offset) das Feld wayfindingLinkages: aktive MapLocation-Treffer mit mapLocationId, svgId, mapLocationName, floorId, floorName, floorNumber (für v0/Plan, wenn floor/location in Stammdaten leer). Eine zusätzliche DB-Abfrage; Canonical bleibt GET …/wayfinding/floors. |
| CORS | Ja (*) |
Beispiel (Struktur):
{
"success": true,
"data": [
{
"id": "…",
"name": "Shop oder Filiale",
"category": "Fashion",
"isShopLocation": false,
"tags": [],
"logo": "https://…",
"slug": "…"
},
{
"id": "…",
"slug": "ketten-slug",
"name": "Filiale XY",
"category": "Food",
"isShopLocation": true,
"type": "location",
"chain": { "id": "…", "name": "Kette", "logo": "https://…" },
"floor": "EG"
}
],
"total": 42,
"pagination": {
"limit": 100,
"offset": 0,
"hasMore": false,
"nextOffset": null
},
"meta": {
"total": 42,
"limit": 100,
"offset": 0,
"returned": 42,
"centerId": "…",
"centerName": "…",
"breakdown": {
"standaloneShops": 10,
"shopLocations": 32,
"shopLocationsFiltered": 0
}
}
}
Anbieterinformationen (providerInfo)
| Zweck | Optionales Plaintext-Feld für Transparenz-/Pflichtangaben zum Anbieter (z. B. Kennzeichnung auf der Website). Auf der Center-Website wird es in Shop-Detail-Ansichten unauffällig ausgegeben, sofern nach Auflösung ein nicht leerer Wert entsteht. |
| Datenmodell | Shop.providerInfo, ShopLocation.providerInfo, ShopChain.providerInfo — jeweils nullable (keine Pflicht beim Anlegen). |
Telefon (Shop, Filiale, Kette)
| Datenmodell | Shop.phone (Einzelshop), ShopLocation.phone (Filiale), ShopChain.phone (kettenweit, optional) — jeweils nullable. |
| Antwort | Bei Einzelshops mit shopChainId liefert die Route shopChain: { id, phone } (kompakt). Bei Filialen steht phone am Standort, die volle bzw. kompakte Kette inkl. phone in shopChain bzw. chain. |
| Center-Website (Auflösung) | Öffentliche Anzeige: zuerst Standort/Shop, sonst ShopChain.phone, sonst optional eine Nummer anderer Shops derselben Kette im selben Center (Peer-Fallback), falls vorher nichts befüllt ist. |
GET /api/centers/{centerId}/shops (Dashboard, CORS *):
- Standalone-Shop (
isShopLocation: false):providerInfoentspricht dem gespeicherten Shop-Feld. - Filiale (
isShopLocation: true): Top-Level-providerInfoist aufgelöst: zuerst nicht-leerer Text der Filiale, sonst der Shopkette. Im Objektchainist zusätzlichproviderInfoder Kette enthalten (für Clients, die Roh- und Fallback-Daten unterscheiden müssen).
Center-Website-App (apps/center-website): SSR (loadShops) ruft diese Route mit publicWebsite=true auf. Die öffentliche Shop-API und Loader wenden dieselbe Filiale vor Kette-Logik für die Darstellung an; Templates mit eigener Shop-Detailseite binden den Text konsistent ein.
- Filiale (
isShopLocation: true): Top-Level-slugentsprichtShopChain.slug(wie bei Center-Website/api/shops), damit Shop-Detail-URLs/…/shops/{slug}mit SSR-Auflösung übereinstimmen. Zusätzlichtype:"location"zur Abgrenzung vom Einzelshop.
Shop-Kategorien
Empfohlen (Center-SSR / v0)
GET /api/centers/{centerId}/website-categories
| Query | Default | Beschreibung |
|---|---|---|
includeGlobal | true | false = nur center-spezifische Kategorien |
status | Aktiv | Statusfilter für Shop-/Filiale-Zählung |
minShopCount | 0 | Nur Kategorien mit ≥ dieser Shop-Anzahl |
| CORS | Ja (*) |
Antwort: { success, categories, meta } — categories enthält u. a. slug, coverImage, shopCount, shopNames, cardSettings (wie früher Center-Website /api/categories).
Legacy / globaler Katalog
GET /api/categories — weiterhin für Dashboard/Listen; Query u. a. centerId, forServices, includeGlobal (siehe Route).
Category-Themes (Website-Payload)
GET /api/centers/{centerId}/category-themes-for-website
| CORS | Ja (*) |
Antwort: JSON-Array von Themen-Objekten (name, slug, image, shopCount, shopIds, shops, …) — gleiche Semantik wie Center-Website /api/category-themes.
Hinweis: GET …/category-themes (ohne -for-website) liefert das rohe Admin-JSON fürs Cockpit (inkl. Theme-id, verschachtelte Zuordnungen) — für öffentliche Sites nicht diesen Endpunkt verwenden.
News, Events, Angebote, Jobs
Alle unter GET /api/centers/{centerId}/… mit CORS *.
Inhaltskategorien (News / Events / Angebote)
GET /api/content-categories
Redaktionelle ContentCategory-Einträge (nicht Shop-Kategorien unter …/website-categories).
| Query | Beschreibung |
|---|---|
type | news, event oder offer |
centerId | Center-UUID |
includeGlobal | true mit centerId: center-spezifische und globale Kategorien |
Antwort: JSON-Array mit id, name, slug, type, color, icon, …
Filter in Listen: News/Events/Angebote unter …/centers/{centerId}/news|events|offers akzeptieren contentCategorySlug (empfohlen), contentCategoryId oder contentCategory (Name/Slug). News zusätzlich legacy category (Freitext-Feld). Jeder Listeneintrag enthält optional contentCategory.
MCP: cockpit_public_content_categories, Filter in cockpit_public_news|events|offers. AgencyOS-Kontext: include=content_categories oder contentCategory an jedem News/Event/Offer-Eintrag.
v0-Beispiel Kinowerbung: …/news?published=true&contentCategorySlug=kinowerbung statt Titel-Substring filtern.
News
GET /api/centers/{centerId}/news
| Query | Beschreibung |
|---|---|
limit | Default 20 |
offset | Default 0 |
published | true: wie Center-Website / Aktuelles-Bundle (newsVisibleInCenter, publishedNewsStatusFilter, websiteNewsDateFilter inkl. publishEndDate); ohne published und ohne constructionDiary: nur centerId (Legacy für interne Leser ohne Website-Filter). Wird beim Baustellen-Tagebuch ignoriert (constructionDiary hat eigene Pfadlogik). |
forToGo | true wie Bundle: auch toGoExclusive: true-News einschließen (standardmäßig ausgeschlossen) |
category | Legacy: Freitext-Feld News.category (weiterhin unterstützt) |
contentCategoryId | UUID der Inhaltskategorie (ContentCategory) |
contentCategorySlug | Slug der Inhaltskategorie (empfohlen, z. B. kinowerbung) |
contentCategory | Name oder Slug (contains/equals, case-insensitive) |
constructionDiary | true = nur Baustellen-Tagebuch (isConstructionDiary). Wie Aktuelles-SSR für News: newsVisibleInCenter, Status standard Veröffentlicht, websiteNewsDateFilter, toGoExclusive: false, inkl. linkedShops |
featured | true = nur im Cockpit als Highlight markierte News (Startseite/Aktuelles in v0); ohne Parameter: alle sichtbaren News, Highlights zuerst (orderBy featured desc) |
status | Optional; bei Tagebuch: Default über Aktiv → Veröffentlicht wie Center-Website |
{
"success": true,
"data": [ { "id": "…", "title": "…", "slug": "…", "excerpt": "…", "image": "…", "featured": false, "publishDate": "2026-03-01T10:00:00.000Z", "status": "…", "category": "…", "contentCategory": { "id": "…", "name": "Kinowerbung", "slug": "kinowerbung", "type": "news" }, "center": { "id": "…", "name": "…", "slug": "…" } } ],
"meta": { "total": 0, "limit": 20, "offset": 0, "returned": 0, "centerId": "…" }
}
Events
GET /api/centers/{centerId}/events
| Query | Beschreibung |
|---|---|
limit / offset | Pagination (Default 100 / 0) |
featured | true = nur Highlight-Events; Highlights zuerst (orderBy) |
status | Default Aktiv (wie Aktuelles-Bundle); andere Werte nur bei bewusstem Bedarf. |
forToGo | true wie Bundle: auch toGoExclusive-Events einschließen (Standard: ausgeschlossen) |
contentCategoryId | UUID der Inhaltskategorie |
contentCategorySlug | Slug der Inhaltskategorie |
contentCategory | Name oder Slug (contains/equals) |
Filter: deckungsgleich mit aktuelles-bundle bei gleichem status/forToGo: eventVisibleInCenter, websiteEventPublicFilter (endDate ≥ jetzt, publishStartDate/publishEndDate, isActive: true).
Jeder Eintrag enthält u. a. featured (boolean) und optional contentCategory (id, name, slug, type, color, icon).
Angebote (Liste & Einzel)
GET /api/centers/{centerId}/offers
| Query | Beschreibung |
|---|---|
limit / offset | Pagination der Liste (Default 100 / 0) |
featured | true = nur Highlight-Angebote; ohne Parameter: alle sichtbaren Angebote, Highlights zuerst |
contentCategoryId | UUID der Inhaltskategorie |
contentCategorySlug | Slug der Inhaltskategorie |
contentCategory | Name oder Slug (contains/equals) |
offerId | Wenn gesetzt: ein öffentliches Angebot (gleiche Regeln wie Center-Website: status Aktiv, websiteOfferPublicFilter, kein To-Go-exklusiv). Wert kann die UUID (id), das Feld slug oder (Mehr-Center-Veröffentlichung) OfferCenter.slugKey für dieses Center sein. Antwort { success, data } mit flachem shop-Objekt. |
Ohne offerId: Liste wie bisher ({ success, data, meta }). Listeneinträge enthalten optional contentCategory.
Jobs
GET /api/centers/{centerId}/jobs
| Query | Beschreibung |
|---|---|
limit / offset | Pagination (Default 100 / 0) |
status | Optional. Default (leer oder Aktiv): Aktiv | Veröffentlicht | Published — wie aktuelles-bundle. Sonst exakter Cockpit-status-String. |
Filter: zusätzlich websiteJobPublishWindowFilter wie Bundle (publishDate/publishEndDate; leeres Startdatum = „ab sofort“ im Sinne der Helper-Logik).
Aktuelles (Bundle, ein Request)
GET /api/centers/{centerId}/aktuelles-bundle
| CORS | Ja (*) |
| Query | Default | Max | Beschreibung |
|---|---|---|---|
forToGo | — | — | true = To-Go-exklusive News/Events/Offers einschließen |
status | Aktiv | — | Wie Center-Website-Loader: bei Aktiv nutzen News publishedNewsStatusFilter; Angebote publishedOfferStatusFilter (Aktiv | Veröffentlicht | Published); Jobs Aktiv | Veröffentlicht | Published; Events weiterhin status: Aktiv. |
newsLimit | 30 | 500 | Schutz vor extrem großen JSON-Responses |
eventsLimit | 30 | 500 | |
offersLimit | 30 | 500 | |
jobsLimit | 20 | 500 |
Antwort: { success, data: { news, events, offers, jobs }, meta: { limits, returned, … } } — Filterlogik wie loadAktuelles in der Center-Website (Center-Website-SSR ruft das Bundle mit *Limit=500 auf, bis zur Max-Grenze). News, Events und Angebote gelten als zum Center gehörig, wenn sie entweder das Primär-Feld centerId haben oder in der jeweiligen Verknüpfungstabelle (newsCenters, eventCenters, offerCenters) diesem Center zugeordnet sind — analog zu den öffentlichen Listen-APIs (newsVisibleInCenter / eventVisibleInCenter / offerVisibleInCenter in @mall-os/database). Jobs bleiben über centerId am Center gebunden.
Jobs im Bundle können optional attachmentPdfUrl enthalten (URL zum Stellen-PDF, z. B. Bunny); die Center-Website verlinkt darauf in der Job-Detailansicht, wenn gesetzt.
News, Events und Angebote im Bundle enthalten bei Bedarf videoUrl, heroVideoUrl (sowie News hasVideo, Events/Angebote gallery wo im Schema vorhanden) für die öffentliche Detailseite; Werte sind Ro-URLs aus dem CMS (YouTube/Vimeo oder Medien-URL), Auflösung auf CDN/Einbettung erfolgt in der Center-Website.
Bei status=Aktiv (Standard): News mit publishedNewsStatusFilter — Veröffentlicht, Published und Aktiv (letzteres: historisch z. B. nach Freigabe eines Content-Entwurfs, bis alle Datensätze auf Veröffentlicht stehen); Angebote mit publishedOfferStatusFilter — Aktiv, Veröffentlicht, Published, plus websiteOfferPublicFilter (Gültigkeitszeitraum des Angebots und optionales Veröffentlichungsfenster; die öffentliche Liste hängt nicht am DB-Feld isActive, sondern am Redaktions-status und den Datumsfeldern); Events mit status: Aktiv plus websiteEventPublicFilter; Jobs mit status in Aktiv | Veröffentlicht | Published und websiteJobPublishWindowFilter (ohne leeres publishDate auszuschließen; leer = sofort sichtbar). Die Center-Website filtert bei öffentlichem Aufruf zusätzlich nach diesen Statusregeln (Defense in Depth).
Hot Picks (kuratierte Highlights)
GET /api/centers/{centerId}/hotpicks
| CORS | Ja — wie andere öffentliche GET /api/centers/… über Middleware (erlaubte Origins, kein Auth) |
| Zweck | Vom Center im Cockpit gepflegte Highlights mit Bezug zu Shop, Event, News, Angebot, Service oder Reserve-Kampagne. Das Frontend (z. B. v0/Vercel) entscheidet frei über Layout (Slider, Raster, Teaser-Leiste); die API liefert nur Daten. Nicht mit Homepage-Kacheln (…/homepage-tiles) verwechseln — anderer Endpoint und andere Daten. |
| Hinweis | channels (JSON-Array im Datensatz) steuert im Cockpit die Ausspielung pro Kanal; für eine reine Website kann das Client nach Bedarf auswerten oder ignorieren. |
Antwort: { success: true, hotPicks: [ … ] } (max. 50 Einträge, status: active, Sortierung u. a. nach priority, position).
Pro Eintrag u. a.: id, contentType (shop | event | news | offer | service | reserve_campaign), type (z. B. Teaser-Typ), title, description, image, priority, position, startDate, endDate, channels, content (aufgelöste Entität bzw. Teaser-Daten), optional reserveCampaignId / brochurePdfUrl bei entsprechenden Typen.
Discovery: Relativer Pfad auch unter GET …/public-visitor-surface → data.apiHints.hotPicksGet (mit centerId bereits eingesetzt).
Services
GET /api/centers/{centerId}/services
| Query | Default | Beschreibung |
|---|---|---|
limit | 100 | max. 5000 |
offset | 0 | Pagination |
publicWebsite / websitePublic | — | true = wie Center-Website-SSR: status: Aktiv, isActive: true, Veröffentlichungsfenster + categoryRef (id, name, icon, color) |
| CORS | Ja (*) |
Antwort: { success, data, meta } — data ist die Service-Liste.
Büros & Praxen
GET /api/offices
| Query | Beschreibung |
|---|---|
centerId | empfohlen |
type | Typ-Filter oder all |
status | z. B. Aktiv oder all |
| CORS | Ja (*) |
{
"success": true,
"data": [
{
"id": "…",
"name": "…",
"type": "Praxis",
"floor": "1. OG",
"logo": "https://…",
"center": { "id": "…", "name": "…", "slug": "…" },
"officeType": { "id": "…", "name": "…" }
}
],
"count": 1
}
Page Content (CMS-Seiten, z. B. Öffnungszeiten)
GET /api/centers/{centerId}/page-content
| CORS | Ja (*) — nur GET; POST ohne breites CORS |
Center-Website-SSR (loadPageContent) nutzt GET und wendet dieselbe Seitenauswahl wie zuvor an (u. a. Alias service/services, oeffnungszeiten/opening-hours).
{
"pageContents": [
{
"id": "…",
"centerId": "…",
"pageType": "oeffnungszeiten",
"pageTitle": "Öffnungszeiten",
"pageSubtitle": null,
"pageDescription": "…",
"metaTitle": "…",
"metaDescription": "…",
"metaKeywords": null,
"customContent": {},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
]
}
pageType-Werte sind center-abhängig (u. a. shops, gastronomy, oeffnungszeiten, kontakt, …). Zusätzlich gibt es embedded-iframe-pages: eine Zeile pro Center mit customContent.pages (Liste von Einträgen mit id, slug, title, description, iframeUrl, enabled, optionale Meta-Felder). Öffentliche Route der Center-Website: /{centerSlug}/{slug} (Kurzname direkt unter dem Center-Pfad; z. B. eingebettetes Tally-Formular). Die frühere URL /{centerSlug}/form/{slug} leitet dauerhaft auf die neue Adresse um. Speichern/Validierung über dasselbe POST …/page-content wie bei anderen CMS-Seiten (Dashboard).
Homepage-Kacheln
Abgrenzung: Homepage-Kacheln sind die im Cockpit (Webseiten-Tab) gepflegten Kachel-Konfigurationen für die Startseite (Titel, Link, Layout-Größe, Hintergrund). Das sind keine Hot Picks — Hot Picks kommen ausschließlich von GET …/hotpicks (data.apiHints.hotPicksGet). Wortwahl „Kacheln“ nur für diesen Endpoint verwenden, nicht für Hot-Pick-Daten mischen.
GET /api/centers/{centerId}/homepage-tiles
| CORS | Ja (*) |
{
"tiles": [
{
"id": "…",
"centerId": "…",
"title": "…",
"subtitle": null,
"href": "/shops",
"icon": "shopping-bag",
"order": 0,
"mobileSize": "1x",
"desktopSize": "1x1",
"backgroundType": "gradient",
"backgroundConfig": null,
"contentType": null,
"contentConfig": null,
"enabled": true,
"gridColumnSpan": 1,
"gridRowSpan": 1
}
]
}
Themes: Gastronomie, Kategorien, Büros
| Route | CORS (laut Analyse) |
|---|---|
GET /api/centers/{centerId}/gastronomy-themes | Ja (*) |
GET /api/centers/{centerId}/category-themes | Ja (*) |
GET /api/centers/{centerId}/office-themes | Ja (*) |
Antwort: jeweils Array/Objekt mit Theme- und Shop-/Office-Zuordnungen — Live-Response als Referenz nutzen (umfangreiche Includes).
Wayfinding / Centerplan
Center-ID vs. Etagen-ID (Dashboard-URL)
Die Route /dashboard/centerplaene/{id}/edit im Cockpit verwendet {id} = MapFloor.id (Etagen-ID / floorId), nicht ShoppingCenter.id. Die öffentlichen Endpunkte erwarten dagegen centerId = UUID des Centers (typisch aus GET …/centers/by-slug/{slug} → Feld id).
Wird versehentlich die floorId als centerId an GET /api/wayfinding/floors?centerId=… übergeben, liefert die API in der Regel keine passenden Etagen bzw. keine mapLocations — das wirkt wie fehlende Mappings. Korrekt: zuerst Center auflösen, dann centerId setzen.
| Route | Methode | CORS |
|---|---|---|
GET /api/wayfinding/centerplan?centerId={uuid} | GET | Ja (*) |
GET /api/wayfinding/floors?centerId={uuid} | GET | Ja (*) |
POST /api/wayfinding/routing | POST | Ja (*) — Body: Start/End-Waypoints oder Location-IDs, siehe Route |
GET /api/wayfinding/routing?centerId=… | GET | Ja (*) — Touchscreen-Standorte (siehe Route) |
GET /api/centers/{centerId}/entrances | GET | Ja (*) |
Centerplan GET liefert u. a. id, centerId, name, floors (geordnet) — verschachtelte Struktur siehe Prisma-Select in der Route.
Floors GET (/api/wayfinding/floors?centerId=…): Antwort { success, floors }. Jede Etage enthält u. a. mapSvg (SVG-Markup) und mapLocations — das ist die Zuordnungstabelle zwischen SVG und Entitäten (vergleichbar mit „Units“/Flächen im Plan):
Es wird ausschließlich centerId ausgewertet. Zusätzliche Query-Parameter wie includeMapLocations, includeShops usw. gibt es an dieser Route nicht und werden vom Server still ignoriert. mapLocations werden immer vollständig mitgeliefert (für aktive Einträge pro Etage), sofern sie im Cockpit gepflegt sind. Leere mapLocations[] deuten damit nicht auf einen fehlenden Include hin, sondern auf keine Markierungen in der Datenbank oder auf eine falsche Center-UUID (floorId aus der Centerplan-Edit-URL ist ein häufiger Fehler).
| Feld (mapLocation) | Bedeutung |
|---|---|
svgId | Entspricht dem id-Attribut des Elements im SVG (z. B. shop-56). |
type | z. B. shop, service, … |
shop / shopLocation / service / office | Verknüpfte Entität inkl. UUID (shop.id etc.) und Anzeigefelder. |
Hybrid-Centerplan (Raster im SVG)
Manche Pläne sind als eine SVG-Datei ausgeliefert, in der ein Raster-Hintergrund (<image xlink:href="…" /> oder Data-URL) und transparente Vektor-Klickflächen (path/polygon …) kombiniert sind. Die öffentliche Schnittstelle bleibt floors[].mapSvg (ein String) — es gibt keinen separaten „PNG-only“-Endpunkt für die interaktive Karte.
| Feld / Thema | Hinweis für v0 / externe Apps |
|---|---|
mapSvg | Kann sehr groß sein (v. a. bei Base64 eingebettetem Bild). Bei CDN-URL im <image> ist die Nutzlast meist kleiner. |
mapImage | Zusätzliche URL zum Hintergrundbild (wie im Cockpit gespeichert); kann mit href in der SVG übereinstimmen. |
shopViewBoxes | Optional: JSON-String auf der Etage (MapFloor.shopViewBoxes) — Zuordnung svgId → { viewBox, padding? } für scharfen Ausschnitt auf Shop-Detailseiten. Wenn null/leer: Frontend nutzt typischerweise Bounding-Box aus dem DOM (Fallback). |
| Interaktion | Klickflächen sind die Vektor-Elemente mit id; das Hintergrundbild soll im Client pointer-events: none haben (sonst fangen Klicks ab). Referenz: @mall-os/wayfinding / InteractiveFloorPlan in der Center-Website. |
| AgencyOS Kontext | Mit Bearer: GET …/agencyos/v1/centers/{centerId}/context?include=floors_summary — komprimierte Etage ohne Svg-Text (mapSvgChars, Hybrid-Heuristik, Mapping-Zähler). Volles Svg weiter über GET …/wayfinding/floors. Siehe AgencyOS-Integration. |
| Dekoratives | Polygone in <g id="decorative"> bzw. IDs mit Präfix decorative- sind bewusst nicht klickbar auf der Live-Site. |
Redaktions-Workflow: Hybrid-Centerplan (PNG + Polygone).
Zusätzlich liefert die Route pro Etage shopSvgIdsWithOffers: SVG-IDs von Standorten, deren Shop ein aktives Angebot hat (für Hervorhebung im Plan).
Wichtig für v0 / externe Websites: Ohne includeWayfindingLinkages=true enthält die Shop-Liste kein svgId. Klicks auf SVG-Flächen primär über GET …/wayfinding/floors → mapLocations (Treffer: svgId === geklickte DOM-id). Mit includeWayfindingLinkages=true liefert jeder Shop/Filiale optional wayfindingLinkages[] — praktisch, wenn floor/location oft leer sind und trotzdem an svgId/Etage angebunden werden soll. Fehlt svgId überall, ist die Zuordnung im Cockpit noch nicht gesetzt — nicht zuverlässig über Namensgleichheit matchen.
Routing: Dashboard-API (POST …/wayfinding/routing)
Berechnung über Wegpunkte und Kanten in der Datenbank (vom Cockpit gepflegt). Typischer Ablauf für eine Website:
- Start:
GET /api/centers/{centerId}/entrances→ Liste von Eingangs-Waypoints;entrances[].idalsstartWaypointId(oder alternativstartLocationId= UUID einerMapLocation, falls der Start an einer Location hängt). - Ziel:
endLocationId=mapLocations[].id(UUID derMapLocationausGET …/wayfinding/floors), nicht die SVG-Flächen-id (svgId). Die API sucht einen aktiven Waypoint mitlocationId= dieser UUID. - Body (minimal): z. B.
{ "startWaypointId": "…", "endLocationId": "…" }— vollständige Felder und Optionen (avoidStairs,preferElevator, …) sieheapps/dashboard/src/app/api/wayfinding/routing/route.ts.
Rate Limit: ca. 90 Anfragen / Minute / IP; bei 429 nutzerfreundlich reagieren (CORS bleibt gesetzt).
Routen-Netz in der SVG (#routes / #Routes)
Viele Centerpläne enthalten zusätzlich (oder statt durchgängig gepflegtem Graph) eine SVG-Gruppe mit id="routes" oder id="Routes". Darin liegen typischerweise line, path, polyline, polygon — sie beschreiben das laufbare Netz (Kanten) in SVG-Koordinaten.
| Aspekt | Hinweis |
|---|---|
| Zweck | Datenquelle für Wegfindung ohne oder neben der Routing-API (z. B. Legacy-Pläne, Mapplic). |
| Sichtbarkeit | Die Ebene ist nicht als „fertige Besucher-Route“ gedacht: in Referenz-Apps wird #routes ausgeblendet (display: none o. Ä.); die tatsächliche Route wird als eigene Linie/Polyline darüber gezeichnet. |
| Algorithmus (Idee) | Aus allen Segment-Endpunkten einen Graphen bilden, kürzesten Pfad (z. B. Dijkstra) von einem Startpunkt (z. B. „Sie sind hier“ / Eingang in SVG-Koordinaten) zum Zielpunkt (z. B. Shop-Fläche oder Anker) berechnen. |
| Anker (optional) | In derselben oder nahegelegener Ebene können Elemente mit IDs wie p-shop-…, p-service-…, p-stele-… u. a. als Anschlusspunkte dienen (präziser als nur die Flächenmitte). Details und Namensschema: interne Doku docs/CENTERPLAN-SINGLE-SOURCE.md. |
| Referenz-Code | Monorepo: @mall-os/wayfinding — u. a. parseSvgRoutes, findPathThroughNetwork (wie Center-Website im Hybrid-Fallback). |
v0 / externes Repo: Entweder POST routing nutzen (wenn Cockpit-Graph gepflegt), oder die #routes-Logik nachbauen gemäß obiger Idee; beides nicht verwechseln: API arbeitet mit Waypoint/Location-UUIDs, SVG-Netz mit Geometrie in mapSvg.
Chatbot (Besucher-KI)
POST /api/ai/visitor-chatbot
| CORS | Ja (*) |
| Rate Limit | ca. 45 Anfragen / Minute / IP (PUBLIC_VISITOR_CHATBOT); 429 mit deutschsprachiger message |
| Body (kurz) | centerId und/oder WordPress-apiKey; Konversation über messages: [{ role, content }] oder Legacy query |
| Auth | Kein User-Login; Ausführung nur serverseitig im Dashboard (OpenAI-Key aus Env/Center-Config). |
UI-Konfiguration (Farben, Consent-Texte, enabled): GET …/public-visitor-surface → data.chatbot.
WordPress-Embed (Center-Website /embed/chat)
Für die iframe-/Embed-Ansicht der Center-Website-App (…/embed/chat) soll der API-Key nicht in der Browser-URL landen. Stattdessen:
| Schritt | Endpoint / Aktion |
|---|---|
| 1. Token holen (serverseitig, z. B. aus WordPress nach Speichern der Verbindung) | POST /api/wordpress/embed-token mit Authorization: Bearer <apiKey> (gleicher Key wie für WordPress-Website). Antwort: { success, token, expiresAt }. |
| 2. Embed-URL | https://<center-website-host>/embed/chat?et=<token> — der Token ist kurzlebig (Standard ca. 24 h). |
| 3. Auflösung (optional, meist serverseitig in der Center-Website) | GET /api/wordpress/embed-resolve?et=… → { success, centerId, slug }. |
Voraussetzung im Dashboard: Umgebungsvariable COCKPIT_WP_EMBED_TOKEN_SECRET (starkes Secret); ohne Secret liefern embed-token und embed-resolve 503 — dann weiterhin Legacy ?apiKey= auf /embed/chat möglich (nicht empfohlen).
Embed-Konfiguration für WordPress-Plugin (Slug, Chatbot-Darstellung): GET /api/wordpress/embed-config mit Authorization: Bearer <apiKey> — der Query-Parameter ?apiKey= ist nicht mehr nötig.
Antwort (Auszug), wenn success: true:
| Feld | Bedeutung |
|---|---|
centerId, slug | Center-Zuordnung |
chatbotDisplay | u. a. primaryColor, enabled, variant, position — aus dem Chatbot-Tab |
wordPressWidget | Texte und Schnellthemen für das WordPress-Plugin (Single Source of Truth, kein manuelles Duplikat auf der WP-Seite) |
wordPressWidget.greetingMessage | Begrüßung (Chatbot-Tab) |
wordPressWidget.popularTopics | Array { label, query } — eine Zeile pro Eintrag aus „Rotierende Platzhalter“ |
wordPressWidget.consentTitle / consentDescription / privacyPolicyUrl | Consent-UI im Widget |
Das Plugin merged diese Felder serverseitig in die Shortcode-/Ambient-Konfiguration (Shortcode-Attribute haben bei expliziter Angabe Vorrang) und cacht die API-Antwort kurz (Transient, TTL z. B. 5 Minuten). Nach Verbindung trennen wird der Cache invalidiert.
Hinweis: Der Chat im WordPress-Shortcode läuft über den PHP-Proxy (admin-ajax.php), nicht über /embed/chat; dort schützt u. a. ein WordPress-Nonce die Proxy-Route.
Optional / später (P1-Rest)
| Thema | Hinweis |
|---|---|
| OpenAPI / Schema-Datei | Maschinenlesbarer Vertrag |
Dedizierte …/construction-diary | Falls gewünscht Alias ohne News-Query — aktuell: …/news?constructionDiary=true |
Copy-Paste: Kontext für v0 / KI
v0 Custom Instructions sind auf 10×5000 Zeichen begrenzt — Slot-Plan und Teil-Kästen (A–J): v0 + Cockpit (So geht’s).
Der gesamte Block hier darunter bleibt für einen langen Chat, Cursor oder andere Tools ohne dieses Limit sinnvoll.
Alles in einem Text (für Chat & Co.)
Baue eine öffentliche Center-Website (Next.js, Server Components wo möglich).
API-Basis: https://dashboard.cockpit-os.de (Staging: Basis-URL anpassen)
Ablauf:
1) Center auflösen: GET /api/centers/by-slug/{slug} → id ist centerId (UUID).
Alternativ Custom Domain: GET /api/centers/by-domain?domain={hostname}
WICHTIG: Die UUID in der Browser-URL …/dashboard/centerplaene/…/edit ist die Etagen-ID (floorId), NICHT centerId — nie für ?centerId= oder /centers/{id}/ verwenden.
2) Feldsicheres Bundle (Branding, Chatbot-UI, Wayfinding-Hints): GET /api/centers/{centerId}/public-visitor-surface
3) Theme/UI-Daten (voll, template-abhängig): GET /api/centers/by-slug/{slug}/theme-config
4) Inhalte (centerId einsetzen):
- Shops (wie Center-Website-SSR): GET /api/centers/{centerId}/shops?publicWebsite=true&status=Aktiv&limit=5000&offset=0 — optional &includeWayfindingLinkages=true für Plan-Hints pro Shop
- Shop-Kategorien (Zählung/Overrides): GET /api/centers/{centerId}/website-categories?status=Aktiv&includeGlobal=true&minShopCount=1
- Category-Themes (Website): GET /api/centers/{centerId}/category-themes-for-website
- News: GET /api/centers/{centerId}/news?published=true&limit=20
- Highlights (Startseite): GET /api/centers/{centerId}/news?published=true&featured=true&limit=6 (analog …/events?featured=true, …/offers?featured=true)
- Oder Aktuelles einmal: GET /api/centers/{centerId}/aktuelles-bundle
- Hinweis Sichtbarkeit: `news?published=true`, `events`, `jobs` und `aktuelles-bundle` werten dieselbe `@mall-os/database`-Zeitfenster-Logik aus — Inhalte fallen aus der Liste, wenn Start/Ende im Cockpit erreicht sind (bei Next.js ISR/tRPC o. Ä. weiterhin Revalidierung beachten).
- Hot Picks (kuratierte Highlights, frei layouten): GET /api/centers/{centerId}/hotpicks — auch data.apiHints.hotPicksGet nach Schritt 2
- Baustellen-Tagebuch: GET /api/centers/{centerId}/news?constructionDiary=true
- Einzel-Angebot: GET /api/centers/{centerId}/offers?offerId={uuid|slug|slugKey}
- Events: GET /api/centers/{centerId}/events
- Angebote: GET /api/centers/{centerId}/offers
- Jobs: GET /api/centers/{centerId}/jobs
- Services (wie Center-Website-SSR): GET /api/centers/{centerId}/services?publicWebsite=true&limit=5000&offset=0
- Büros: GET /api/offices?centerId={centerId}&status=Aktiv
- CMS-Seiten (Öffnungszeiten etc.): GET /api/centers/{centerId}/page-content
- Homepage-Kacheln: GET /api/centers/{centerId}/homepage-tiles
- Centerplan-Metadaten: GET /api/wayfinding/centerplan?centerId={centerId} (404 möglich, dann nur Floors nutzen)
- Etagen/Karte inkl. mapLocations: GET /api/wayfinding/floors?centerId={centerId} — für Medien-URLs ggf. gleiche CDN-Auflösung wie Center-Website (`resolveMediaUrl`)
- SVG-id (z. B. shop-56) → Shop-UUID: nur über floors[].mapLocations (svgId + shop.id / shopLocation / service), nicht über die Shops-Liste allein
- Eingänge: GET /api/centers/{centerId}/entrances — entrances[].id = startWaypointId für Routing
- Route (DB-Graph): POST /api/wayfinding/routing mit startWaypointId (aus entrances) und endLocationId = mapLocations[].id (UUID), nicht svgId; 429 möglich
- Route (SVG-Netz): in floors[].mapSvg Gruppe id routes oder Routes (line/path/polyline/polygon) ausblenden, Graphen aus Segmenten, kürzesten Pfad in SVG-Koordinaten, eigene Overlay-Linie zeichnen; Details docs/CENTERPLAN-SINGLE-SOURCE.md
- Chat: POST /api/ai/visitor-chatbot (centerId im Body; kein OpenAI-Key im Frontend)
- DOOH (im Cockpit angelegte Playlists extern abspielen, nur wenn Website aktiv):
- GET /api/centers/{centerId}/dooh/public/playlists — Slugs & Metadaten
- GET /api/centers/{centerId}/dooh/public/playlist?slug={slug} — Items für Player
- GET /api/centers/{centerId}/dooh/public/active — Kurzform Slug idle
- GET /api/centers/{centerId}/dooh/public/local-hero — Local-Hero-Karten optional
Response-Formen (wichtig für korrektes Parsing):
- GET by-slug: flaches JSON OHNE { success, data } — Felder z. B. id, name, slug, logo, logoUrl (logo und logoUrl oft identisch), baseColor, secondaryColor, urls.
- GET public-visitor-surface: { success: true, data: { schemaVersion, center, seo, chatbot, centerplan, features, apiHints, … } }.
Branding: data.center (logo, logoUrl, baseColor, secondaryColor, …). Chat-UI-Farben/Texte: data.chatbot (z. B. primaryColor, enabled, consentTitle).
apiHints enthält u. a. doohPublicPlaylistsListGet, doohPublicPlaylistBySlugGet (Slug an URL anhängen), doohPublicActiveGet, doohPublicLocalHeroGet — relative Pfade, Basis = API-Host.
- GET …/dooh/public/playlists: { success, playlists: [ { slug, name, itemCount, currentlyBroadcasting, … } ] }.
- GET …/dooh/public/playlist?slug=…: { success, playlist: { id, name, slug, items: [ { type, payload, durationSeconds, sortOrder } ] } | null } — playlist null wenn inaktiv oder außerhalb Gültigkeit.
- GET …/dooh/public/active | local-hero: { success, playlist? } bzw. { success, items }.
- GET …/shops: { success, data: [ … ], total, pagination }. Shop-Medien: Felder logo, coverImage (kein einheitliches imageUrl über alle Endpoints). Optional meta.wayfindingLinkagesIncluded + pro Eintrag wayfindingLinkages[] bei includeWayfindingLinkages=true.
- GET …/wayfinding/floors: { success, floors: [ { mapSvg, mapLocations, shopSvgIdsWithOffers?, … } ] }. mapLocations: svgId, type, shop?, shopLocation?, service?, office? — Mapping SVG-DOM ↔ Entitäts-UUID. Parameter centerId = Center-UUID (by-slug), nicht floorId aus Centerplan-Edit-URL.
- Viele andere Reads: { success, data } oder eigene Keys — Live-Response prüfen.
Öffnungszeiten (openingHours) — Parsing & Anzeige:
- Feld heißt oft openingHours (Shops, Filialen, teils Services). Kann fehlen (null), ein JSON-String sein oder (nach parse) ein Objekt.
- Typisches Wochen-Objekt (Keys englisch, klein): monday … sunday. Pro Tag nicht einheitlich:
- Geöffnet mit Textspanne: { "hours": "10:00 - 20:00" } o. ä.
- Geschlossen z. B.: { "open": "closed", "close": "closed" } — oder nur hours mit "Geschlossen"; immer defensiv prüfen.
- Beispiel-Struktur (vereinfacht):
{"monday":{"hours":"10:00 - 20:00"},"tuesday":{"hours":"10:00 - 20:00"},"wednesday":{"hours":"10:00 - 20:00"},"thursday":{"hours":"10:00 - 20:00"},"friday":{"hours":"10:00 - 20:00"},"saturday":{"hours":"10:00 - 20:00"},"sunday":{"open":"closed","close":"closed"}}
- Pflicht für die UI: Niemals rohes JSON für Besucher ausgeben. Immer in lesbare Zeilen übersetzen (z. B. Tabelle oder Liste: „Mo–Sa 10:00–20:00“, „So geschlossen“).
- Implementierung: Wenn string → JSON.parse in try/catch; wenn kein Objekt oder unbekannte Form → Fallback („Öffnungszeiten siehe vor Ort“ oder String anzeigen, wenn sinnvoll).
- Wochentage in der UI dürfen auf Deutsch beschriftet sein (Montag … Sonntag), Keys in den Daten bleiben englisch.
Besucher-Chatbot (gleiche Datenlogik wie Cockpit-Besucher-KI, ohne OpenAI-Key im Browser):
- Zuerst GET public-visitor-surface → data.chatbot: enabled, primaryColor, consentTitle, consentDescription, privacyPolicyUrl, … Wenn enabled=false → kein Chat-Widget anzeigen.
- Vor der ersten Nutzernachricht: Zustimmung einholen (Texte aus data.chatbot); erst danach an die API senden.
- Konversation: POST /api/ai/visitor-chatbot — JSON u. a. centerId (UUID von GET by-slug → id), messages: [{ "role": "user"|"assistant", "content": "…" }]. Keine Secrets/OpenAI-Keys im Client.
- Optional stream: true — Antwort als Server-Sent Events (text/event-stream); sonst kompakte JSON-Antwort — beides in der Route unterstützt.
- HTTP 429: kurzer Hinweis für Nutzer (z. B. später erneut versuchen), kein technischer Text.
Medien-URLs (Logos, Bilder):
- API liefert oft relative Pfade (/uploads/…, centers/…, global/…) oder absolute URLs.
- Für konsistente Bilder im Browser: relative Pfade mit CDN-Basis prefixen — Referenz wie Production:
Basis-URL Default https://cockpitos.b-cdn.net (Env: NEXT_PUBLIC_BUNNY_CDN_URL oder BUNNY_CDN_URL).
Beispiel: /uploads/x.png → https://cockpitos.b-cdn.net/uploads/x.png
- Code-Referenz (zum Portieren): Center-Website resolveMediaUrl in lib/url-resolver.ts
Typen / OpenAPI:
- Keine OpenAPI-Datei. Stärkste Schema-Quelle für public-visitor-surface: TypeScript-Typ PublicVisitorSurfaceV1 in apps/dashboard/src/lib/build-public-visitor-surface.ts
Auth:
- Dieser Vertrag = nur öffentliche Reads (+ visitor-chatbot POST ohne User-Session). website-config und Dashboard-Schreibroutes: Session/Cookies, NICHT für anonyme Cross-Origin-Clients.
Hinweise:
- Responses nutzen teils { success, data }, teils flaches Objekt oder { tiles }, { pageContents } — jeweils prüfen.
- Öffentliche Read-Routen setzen CORS * für Browser von anderer Origin; Schreib-Routen (z. B. page-content POST) nicht ohne Auth von Cross-Origin aufrufen.
- Chatbot- und Routing-POST können 429 liefern — Retry-After beachten oder Anfragen drosseln.
- Keine Secrets im Client; website-config GET ist nur für eingeloggte Dashboard-Nutzer.
- Ausführliche Beispiel-JSON und Tabellen: Cockpit-Doku „public-center-website-api-beispiele-und-medien“ (Developer Guide).
Pflege dieses Dokuments
- Nach API-/CORS-Änderungen: Tabellen,
public-visitor-surface-Whitelist und DOOH…/dooh/public/…im Code abgleichen. - Nach P1-Implementierung: neue URLs und Beispiele ergänzen.
- Bei Schema-Änderungen in Prisma: Beispiel-JSON anpassen oder auf „Live-Response“ verweisen.
Code-Referenz: apps/dashboard/src/app/api/…
Nutzungsstatistik: Seitenaufrufe werden anonymisiert erfasst. Im Umami-Dashboard nach diesem Pfad filtern: /en/developer-guide/public-center-website-api-vertrag