Zum Hauptinhalt springen

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 fetchnicht der ZIP-Workflow für Website-Templates im Cockpit; siehe Templates — Intro),
  • als Grundlage für Dogfooding (server-data-loader schrittweise 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/categories und /api/category-themes rufen dieselben Helper auf (BFF für Browser). /api/centers/centerplan bleibt 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

FeldWert
Produktions-Basis-URLhttps://dashboard.cockpit-os.de
Konfiguration im CodegetDashboardApiUrl() 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-Typeapplication/json
FehlerTypisch { 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-config GET, Schreiboperationen ohne harte Auth bleiben nicht für anonyme Cross-Origin-Clients gedacht.
  • public-visitor-surface liefert nur eine Whitelist (keine Secrets, kein volles themeOverrides). Erweiterungen nur nach kurzem Privacy-/Security-Check.
  • page-content POST bleibt ohne breites CORS — öffentliche Schreibzugriffe ohne Token sind unerwünscht.
  • Chatbot & Routing POST: POST /api/ai/visitor-chatbot und POST /api/wayfinding/routing sind Redis-basiert pro Client-IP begrenzt (Zeitfenster über rate-limit.ts); Antwort 429 inkl. CORS * und Header Retry-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}

CORSJa (*)
AntwortObjekt mit u. a. id, name, slug, websiteTemplate, 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",
"websiteTemplate": "mec-template-a",
"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}

Parameterdomain — Hostname ohne Pfad, z. B. www.palais-vest.de oder palais-vest.de
CORSJa (*)
Antwort{ success, center: { id, slug, websiteTemplate, … } }websiteTemplate auch in GET …/by-slug/{slug} (flaches Objekt, Feld websiteTemplate)

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": "…",
"websiteTemplate": "mec-template-green",
"comingSoonEnabled": false
}
}

Theme & Website-Konfiguration (öffentlich)

Theme-Konfiguration (SPA / Template)

GET /api/centers/by-slug/{slug}/theme-config

CORSJa (*)
AntwortObjekt 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

CORSJa (*)
VoraussetzungCenter 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**, **mecShared**, **v0Integration**, apiHints } }
  • templatePublicContent: { templateId, rootKey, content } — besucher-sichere Teilmenge von themeOverrides.templateContent.{rootKey} (alle Website-Templates: RGW, ILG, MEC, Goldbeck, …). Enthält u. a. Hero-Slides, Footer-Optionen. Ausgeschlossen: formRecipients, Passwörter, API-Keys. v0/Claude: nie GET …/website-config ohne Auth (401) — stattdessen dieses Feld.

  • mecShared (nur MEC Classic: A/C/D/Grooss/Green): { pageVisibility } — entspricht templateContent.mec.pageVisibility (Root-Key, template-übergreifend). Nur explizit false = Seite aus. v0: Navigation filtern; direkte URLs → 404.

  • v0Integration: Kurzreferenz Lesen (öffentlich) vs. Schreiben (MCP/AgencyOS) — identisch zu cockpit_website_config_schemav0Integration.

  • apiHints.pageContentGet / homepageTilesGet: relative Pfade zu öffentlichen CMS-Seiten bzw. Startseiten-Kacheln.

  • data.center.openingHours / openingHoursNote / specialDays: Center-Öffnungszeiten aus Stammdaten (Cockpit „Center bearbeiten“ / cockpit_update_center). JSON wird serverseitig geparst; Freitext bleibt String. specialDays: separate Spalte hat Vorrang und wird in strukturiertes openingHours gespiegelt, wenn regularHours vorhanden ist.

  • visitorPrivacy: gebündelte Felder für Cookie-Banner-Texte (aus themeOverrides), Chatbot-Consent und Datenschutz-Link (aus AIConfiguration.parameters), plus relative Pfade /datenschutz und /impressumgleiche Quelle wie Dashboard „Datenschutz & Besucher-Consent“ und GET /api/wordpress/embed-config.

  • chatbot: nur UI-Felder (Farben, Texte, Consent-Links) — keine apiKey / 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); nie analyticsPagePassword.


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.

MethodePfadZweck
GET/api/centers/{centerId}/dooh/public/playlistsAlle 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/activeKurzform für Slug idle (Idle-Werbeslot). Antwort: { success, playlist }.
GET/api/centers/{centerId}/dooh/public/local-heroLocal-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

QueryDefaultBeschreibung
limit100Seitengröße (max. 5000 pro Request)
offset0Offset
publicWebsite / websitePublictrue = gleiche Filter wie Center-Website-SSR: status (Default Aktiv) + publishStartDate/publishEndDate auf Shop, Filiale und Kette
statusbei publicWebsite: AktivNur in Kombination mit publicWebsite maßgeblich; ohne publicWebsite weiterhin „nicht Inaktiv“
includeWayfindingLinkagestrue = 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)

ZweckOptionales 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.
DatenmodellShop.providerInfo, ShopLocation.providerInfo, ShopChain.providerInfo — jeweils nullable (keine Pflicht beim Anlegen).

Telefon (Shop, Filiale, Kette)

DatenmodellShop.phone (Einzelshop), ShopLocation.phone (Filiale), ShopChain.phone (kettenweit, optional) — jeweils nullable.
AntwortBei 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): providerInfo entspricht dem gespeicherten Shop-Feld.
  • Filiale (isShopLocation: true): Top-Level-providerInfo ist aufgelöst: zuerst nicht-leerer Text der Filiale, sonst der Shopkette. Im Objekt chain ist zusätzlich providerInfo der 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-slug entspricht ShopChain.slug (wie bei Center-Website /api/shops), damit Shop-Detail-URLs /…/shops/{slug} mit SSR-Auflösung übereinstimmen. Zusätzlich type: "location" zur Abgrenzung vom Einzelshop.

Shop-Kategorien

Empfohlen (Center-SSR / v0)

GET /api/centers/{centerId}/website-categories

QueryDefaultBeschreibung
includeGlobaltruefalse = nur center-spezifische Kategorien
statusAktivStatusfilter für Shop-/Filiale-Zählung
minShopCount0Nur 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).

QueryBeschreibung
typenews, event oder offer
centerIdCenter-UUID
includeGlobaltrue 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 (Website-Bereich): …/news?published=true&contentCategorySlug=kinowerbung statt Titel-Substring filtern.

v0-Stele (Touchscreen): …/news?published=true&forSignage=true — nur News mit Cockpit-Schalter Auf Stele anzeigen (showOnSignage: true in der Antwort).

News

GET /api/centers/{centerId}/news

QueryBeschreibung
limitDefault 20
offsetDefault 0
publishedtrue: 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).
forToGotrue wie Bundle: auch toGoExclusive: true-News einschließen (standardmäßig ausgeschlossen)
forSignagetrue = nur News mit Auf Stele anzeigen im Dashboard (showOnSignage); für v0-Touchscreen-Stelen
categoryLegacy: Freitext-Feld News.category (weiterhin unterstützt)
contentCategoryIdUUID der Inhaltskategorie (ContentCategory)
contentCategorySlugSlug der Inhaltskategorie (empfohlen, z. B. kinowerbung)
contentCategoryName oder Slug (contains/equals, case-insensitive)
constructionDiarytrue = nur Baustellen-Tagebuch (isConstructionDiary). Wie Aktuelles-SSR für News: newsVisibleInCenter, Status standard Veröffentlicht, websiteNewsDateFilter, toGoExclusive: false, inkl. linkedShops
featuredtrue = nur im Cockpit als Highlight markierte News (Startseite/Aktuelles in v0); ohne Parameter: alle sichtbaren News, Highlights zuerst (orderBy featured desc)
statusOptional; bei Tagebuch: Default über AktivVeröffentlicht wie Center-Website
{
"success": true,
"data": [ { "id": "…", "title": "…", "slug": "…", "excerpt": "…", "image": "…", "featured": false, "showOnSignage": true, "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

QueryBeschreibung
limit / offsetPagination (Default 100 / 0)
featuredtrue = nur Highlight-Events; Highlights zuerst (orderBy)
statusDefault Aktiv (wie Aktuelles-Bundle); andere Werte nur bei bewusstem Bedarf.
forToGotrue wie Bundle: auch toGoExclusive-Events einschließen (Standard: ausgeschlossen)
contentCategoryIdUUID der Inhaltskategorie
contentCategorySlugSlug der Inhaltskategorie
contentCategoryName 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

QueryBeschreibung
limit / offsetPagination der Liste (Default 100 / 0)
featuredtrue = nur Highlight-Angebote; ohne Parameter: alle sichtbaren Angebote, Highlights zuerst
contentCategoryIdUUID der Inhaltskategorie
contentCategorySlugSlug der Inhaltskategorie
contentCategoryName oder Slug (contains/equals)
offerIdWenn 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

QueryBeschreibung
limit / offsetPagination (Default 100 / 0)
statusOptional. Default (leer oder Aktiv): Aktiv | Veröffentlicht | Published — wie aktuelles-bundle. Sonst exakter Cockpit-status-String.
featuredtrue = nur Highlight-Jobs; ohne Parameter: alle sichtbaren Jobs, Highlights zuerst (orderBy featured desc)

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

CORSJa (*)
QueryDefaultMaxBeschreibung
forToGotrue = To-Go-exklusive News/Events/Offers einschließen
statusAktivWie Center-Website-Loader: bei Aktiv nutzen News publishedNewsStatusFilter; Angebote publishedOfferStatusFilter (Aktiv | Veröffentlicht | Published); Jobs Aktiv | Veröffentlicht | Published; Events weiterhin status: Aktiv.
newsLimit30500Schutz vor extrem großen JSON-Responses
eventsLimit30500
offersLimit30500
jobsLimit20500

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. Bundle-Jobs enthalten featured (boolean); Sortierung: Highlights zuerst, dann publishDate absteigend.

Angebote im Bundle (und Einzelabruf GET …/offers?offerId=) können optional attachmentPdfUrl enthalten (Prospekt/Wochenangebot als PDF); die Center-Website zeigt einen Download-Link auf der Angebots-Detailseite, 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 publishedNewsStatusFilterVeröffentlicht, Published und Aktiv (letzteres: historisch z. B. nach Freigabe eines Content-Entwurfs, bis alle Datensätze auf Veröffentlicht stehen); Angebote mit publishedOfferStatusFilterAktiv, 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

CORSJa — wie andere öffentliche GET /api/centers/… über Middleware (erlaubte Origins, kein Auth)
ZweckVom 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.
Hinweischannels (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-surfacedata.apiHints.hotPicksGet (mit centerId bereits eingesetzt).


Services

GET /api/centers/{centerId}/services

QueryDefaultBeschreibung
limit100max. 5000
offset0Pagination
publicWebsite / websitePublictrue = wie Center-Website-SSR: status: Aktiv, isActive: true, Veröffentlichungsfenster + categoryRef (id, name, icon, color)
featuredtrue = nur Highlight-Services; ohne Parameter: alle sichtbaren Services, Highlights zuerst (orderBy featured desc, dann name asc)

| CORS | Ja (*) |

Antwort: { success, data, meta }data ist die Service-Liste.


Büros & Praxen

GET /api/offices

QueryBeschreibung
centerIdempfohlen
typeTyp-Filter oder all
statusz. 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

RouteCORS (laut Analyse)
GET /api/centers/{centerId}/gastronomy-themesJa (*)
GET /api/centers/{centerId}/category-themesJa (*)
GET /api/centers/{centerId}/office-themesJa (*)

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.

RouteMethodeCORS
GET /api/wayfinding/centerplan?centerId={uuid}GETJa (*)
GET /api/wayfinding/floors?centerId={uuid}GETJa (*)
POST /api/wayfinding/routingPOSTJa (*) — Body: Start/End-Waypoints oder Location-IDs, siehe Route
GET /api/wayfinding/routing?centerId=…GETJa (*) — Touchscreen-Standorte (siehe Route)
GET /api/centers/{centerId}/entrancesGETJa (*)

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 }. Optional omitMapSvg=true (Metadaten + mapLocations, ohne mapSvg-Body — für MCP/v0-Etagen-Inventar) und floorNumber (nur eine Etage). 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):

Center-Website SSR (Lazy Floors): /centerplan, /wayfinding und /embed/centerplan laden beim SSR nur Metadaten aller Etagen (omitMapSvg=true) plus volles mapSvg der Start-Etage (Default oder Embed-Query floorNumber). Weitere Etagen holt der Browser über GET /api/centers/floors?centerId={uuid}&floorNumber={n} (Center-Website-BFF, Logo-Auflösung wie bei /api/centers/centerplan). Shop-Detail, Einkaufswelt und Kaufland-Stacked nutzen weiterhin den vollen loadCenterplan()-Fetch.

Center-Website Client (3D Code-Splitting): ModularIsometricWayfinding (Three.js / React Three Fiber) wird per next/dynamic (ssr: false) erst geladen, wenn viewMode=3d — der Initial-JS-Bundle der Centerplan-Seite bleibt ohne Three.js kleiner; bei Embed mit viewMode=3d startet der Chunk-Download parallel zum First Paint.

Query-Parameter (v0 / KI-Generatoren)

Ausgewertet werden centerId (Pflicht), optional omitMapSvg=true (kein mapSvg-Text, dafür mapSvgChars/hasMapSvg — Etagen-Inventar für MCP) und floorNumber (nur eine Etage). Parameter wie includeMapLocations oder includeShops gibt es nicht und werden ignoriert. mapLocations werden immer vollständig mitgeliefert (für aktive Einträge pro Etage), sofern sie im Cockpit gepflegt sind. Leere mapLocations[] deuten 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
svgIdEntspricht dem id-Attribut des Elements im SVG (z. B. shop-56).
typez. B. shop, service, …
shop / shopLocation / service / officeVerknü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 / ThemaHinweis für v0 / externe Apps
mapSvgKann sehr groß sein (v. a. bei Base64 eingebettetem Bild). Bei CDN-URL im <image> ist die Nutzlast meist kleiner.
mapImageZusätzliche URL zum Hintergrundbild (wie im Cockpit gespeichert); kann mit href in der SVG übereinstimmen.
shopViewBoxesOptional: 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).
InteraktionKlickflä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.
v0 / externe Apps@mall-os/wayfinding ist nicht öffentlich auf npm. v0 portiert die 2D-Klick-Pipeline (buildSvgIdToLocationMap, Event-Delegation) oder nutzt optional einen iframe auf /{slug}/centerplan der Cockpit-Center-Website. Navigation: Plan liefert nur shop.id / service.id — Detail-URLs (/shops/{slug|id}) definiert das v0-Frontend (data.v0Integration.centerplan in public-visitor-surface). Siehe v0 Instructions Teil F.
AgencyOS KontextMit Bearer: GET …/agencyos/v1/centers/{centerId}/context?include=floors_summarykomprimierte Etage ohne Svg-Text (mapSvgChars, Hybrid-Heuristik, Mapping-Zähler). Volles Svg weiter über GET …/wayfinding/floors. Siehe AgencyOS-Integration.
DekorativesPolygone in <g id="decorative"> bzw. IDs mit Präfix decorative- sind bewusst nicht klickbar auf der Live-Site.

Redaktions-Workflow: Hybrid-Centerplan (PNG + Polygone) · KI-Masterprompt für Plan-Bilder.

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/floorsmapLocations (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:

  1. Start: GET /api/centers/{centerId}/entrances → Liste von Eingangs-Waypoints; entrances[].id als startWaypointId (oder alternativ startLocationId = UUID einer MapLocation, falls der Start an einer Location hängt).
  2. Ziel: endLocationId = mapLocations[].id (UUID der MapLocation aus GET …/wayfinding/floors), nicht die SVG-Flächen-id (svgId). Die API sucht einen aktiven Waypoint mit locationId = dieser UUID.
  3. Body (minimal): z. B. { "startWaypointId": "…", "endLocationId": "…" } — vollständige Felder und Optionen (avoidStairs, preferElevator, …) siehe apps/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.

AspektHinweis
ZweckDatenquelle für Wegfindung ohne oder neben der Routing-API (z. B. Legacy-Pläne, Mapplic).
SichtbarkeitDie 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-CodeMonorepo: @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.

Kiosk / Stele: Touchscreen-Standorte (signage-config, touchscreens)

Pflege im Dashboard: Digital Experience → Touchscreens (Wayfinding TouchscreenLocation — pro Stele eine Etage floorId / floorNumber).

RouteInhalt
GET …/signage-config?centerId=Signage-Branding, URLs, signageConfig.touchscreens[], apiHints.touchscreensGet
GET …/public-visitor-surfaceWebsite-Bundle + optional data.kiosk.touchscreens[], data.v0Integration.kiosk
GET /api/touchscreens?centerId={uuid}Gleiche Standort-Liste, CORS *

Felder pro Touchscreen: id (=touchscreenId für Handoff), name, location, floorId, floorName, floorNumber (0=EG, 1=OG, -1=UG), status, optional assignedMapLocationId.

v0-Flow: Pro physischem Gerät ?touchscreenId={uuid} oder Env NEXT_PUBLIC_KIOSK_TOUCHSCREEN_IDEtage aus API, nicht NEXT_PUBLIC_KIOSK_FLOOR. Centerplan-Slide nur floorNumber dieser Stele; Companion-QR ?floorNumber={n}; Handoff POST …/handoff-sessions mit touchscreenId + optional location.floor.

MCP: cockpit_public_visitor_surface, cockpit_public_integration_docs Topic kiosk.


Chatbot (Besucher-KI)

POST /api/ai/visitor-chatbot

CORSJa (*)
Rate Limitca. 45 Anfragen / Minute / IP (PUBLIC_VISITOR_CHATBOT); HTTP 429 mit message „Zu viele Anfragen…“
OpenAI-ProviderBei OpenAI-429/401 antwortet Cockpit oft mit HTTP 502, code: openai_rate_limit bzw. openai_unauthorizedkein per-Center-„Kontingent“-Schalter im Dashboard
Body (kurz)centerId und/oder WordPress-apiKey; Konversation über messages: [{ role, content }] oder Legacy query
AuthKein User-Login; Ausführung nur serverseitig im Dashboard (OpenAI-Key aus Env/Center-Config).

UI-Konfiguration (Farben, Consent-Texte, enabled): GET …/public-visitor-surfacedata.chatbot.

Antwort-Felder & Verdrahtung (v0-Website)

Was: POST /api/ai/visitor-chatbot liefert neben answer strukturierte Follow-ups — v0 muss sie rendern und mit Centerplan/Navigation verknüpfen.

Warum: Gleiche API wie Concierge/Companion; ohne UI-Verdrahtung bleiben Karten-Chips ungenutzt.

FeldTypv0-Aktion
data.contentBlocksobjectPflicht in v0: blocks[] mit kind shops | offers | … — Karussell-Karten unter der Antwort (nicht nur Markdown-Liste)
data.itemsShopItem[]Shop-Karten/Chips wenn kein shops-Block
data.actions{ type, targetId, label? }[]Buttons (aus KI-Antwort geparst)
data.suggestedActionsgleiche StrukturIntent-Chips (z. B. „Auf Karte zeigen")
data.detectedIntentstring | nulloptional für Analytics/UI
data.placePresentationstring | nullDarstellung der Item-Karten

Aktionstypen → Handler:

typeVerhalten in v0
open_shoprouter.push('/shops/{slug|id}') — UUID/Slug aus items[] zu targetId auflösen
open_servicerouter.push('/services/{slug|id}')
show_on_mapCenterplan öffnen: iframe-URL {embedUrl}&shop={uuid} oder Seite /centerplan mit Shop-Fokus; Sonder-targetId parking | center | shops = Plan ohne Shop
start_routePOST /api/wayfinding/routing → Plan mit wayfinding=1&shop={uuid}

Discovery: GET …/public-visitor-surfacedata.v0Integration.chatbot (responseFields, centerplanWiring). Embed postMessage: cockpit:centerplan:open-detail (siehe #centerplan-v0-navigation).

Instructions: v0 Teil C + Chatbot-Verdrahtung Chat-Prompt.

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:

SchrittEndpoint / 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-URLhttps://<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:

FeldBedeutung
centerId, slugCenter-Zuordnung
chatbotDisplayu. a. primaryColor, enabled, variant, position — aus dem Chatbot-Tab
wordPressWidgetTexte und Schnellthemen für das WordPress-Plugin (Single Source of Truth, kein manuelles Duplikat auf der WP-Seite)
wordPressWidget.greetingMessageBegrüßung (Chatbot-Tab)
wordPressWidget.popularTopicsArray { label, query } — eine Zeile pro Eintrag aus „Rotierende Platzhalter“
wordPressWidget.consentTitle / consentDescription / privacyPolicyUrlConsent-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.



Centerplan in v0 (Navigation & Klicks)

Was: Öffentliche APIs liefern Plan-Daten (mapSvg, mapLocations, Config) — nicht das React-Paket @mall-os/wayfinding (privat im Monorepo).

Warum: v0-Projekte haben eigene Shop-Detailseiten (/shops/…). Der Plan darf nicht auf die Cockpit-Center-Website verlinken.

Wer: v0-Website, Companion (wenn Plan im v0-Repo nachgebaut wird).

Wo:

QuelleInhalt
GET …/public-visitor-surfacedata.centerplan, data.features, data.apiHints, data.v0Integration.centerplan
GET …/wayfinding/floors?centerId=mapSvg, mapLocations[]
MCPcockpit_public_wayfinding_floors, cockpit_public_integration_docs

So bauen (v0):

  1. Server: centerId aus by-slugfloors laden.
  2. Client: buildSvgIdToLocationMap(mapSvg, mapLocations) → klickbare svgIds.
  3. Klick → shop.id / service.idnicht svgId als UUID für APIs.
  4. Hover (2D): mouseover/mouseout auf einzelnen path/g vermeiden — feuern bei jedem Kind-Element neu; Hover bleibt kleben. Stattdessen: mousemove auf dem SVG-RootelementFromPoint + closest POI → eine hoveredSvgId; mouseleave auf SVG → null. Farbe aus data.centerplan.hoverColorSvg.
  5. Navigation in v0: lib/navigation/resolve-href.ts/shops/{slug\|id}, Modal-CTA mit internem <Link> oder router.push.
  6. Optional Embed: iframe auf {cockpitOrigin}/embed/centerplan?…cockpitOrigin = data.center.customDomain oder https://{slug}.cockpit-os.de (nicht websitePublicUrl, das ist die separate v0-URL). partnerOrigin = v0-eigene URL (Env).
ParameterWerteBedeutung
chromeplan | minimal (Default) | standard | fullUI-Preset im iframe
showFloors0 | 1Etagen-Switcher (rechts)
showViewMode0 | 12D/3D-Umschalter
showLegend0 | 1Kategorie-Legende unten
showSidebar0 | 1Shop-Liste links
wayfinding0 (Default) | 1Routen-UI unter Modal
detailModeparent | cockpitv0-Modal per postMessage oder Cockpit-Modal
embedBgtransparent | none | #hex | rgb() | rgba()Hintergrund der Embed-Hülle (default: heller Verlauf)
transparent1Alias für embedBg=transparent
chromeGhost / uiGhost0 | 12D/3D- und Etagen-UI ohne Karten-Hintergrund (v0-Look)
viewMode2d | 3dErzwungene Ansicht wenn showViewMode=0 / chrome=plan
accent / accentColor / primaryColor#hex | rgb() | rgba()Aktive Controls (2D/3D, Etage) — Hintergrund
accentFg / accentForeground / primaryForegroundsichere CSS-FarbeText auf aktivem Control
chromeBg / panelBgsichere CSS-FarbeHintergrund der Control-Panels (wenn nicht chromeGhost)
chromeFg / panelFg / foregroundsichere CSS-FarbeText auf inaktiven Controls
chromeBorder / bordersichere CSS-FarbeRahmen der Panels
chromeMuted / mutedsichere CSS-FarbeReserviert für sekundäre Legende-Texte
chromeRadius / radius032 (Integer, px)Eckenradius der Panels/Buttons
hoverColor / hoverColorSvgsichere CSS-FarbeShop-Hover auf 2D-Plan (Embed-Override)
hoverColor3dsichere CSS-FarbeShop-Hover in 3D-Ansicht (Embed-Override)

Hinweis: v0 kann Controls nicht per CSS von außen stylen (iframe Same-Origin). Farben nur über diese URL-Parameter oder chrome=plan (Controls aus) + eigenes UI in v0.

v0-Branding (Controls im iframe):

/embed/centerplan?slug=palais-vest&chrome=minimal&embedBg=transparent&chromeGhost=1&accent=%23D4FF00&accentFg=%23000&chromeBorder=rgba(0,0,0,0.12)&chromeRadius=16&hoverColor=%23D4FF00&detailMode=parent&hideHeader=1&partnerOrigin={v0Url}

3D, transparent, ohne Controls (v0-Hero o. Ä.):

/embed/centerplan?slug=palais-vest&chrome=plan&viewMode=3d&embedBg=transparent&chromeGhost=1&hideHeader=1&detailMode=parent&partnerOrigin={v0Url}

Transparent (v0-Hintergrund scheint durch):

/embed/centerplan?slug=palais-vest&embedBg=transparent&chromeGhost=1&chrome=minimal&detailMode=parent&hideHeader=1&partnerOrigin={v0Url}

Eigene Hintergrundfarbe (z. B. v0-Seitenfarbe):

/embed/centerplan?slug=palais-vest&embedBg=%23f5f5f0&chrome=minimal&detailMode=parent&hideHeader=1

Hinweis: Der Plan-SVG kann weiterhin ein Cockpit-Hintergrundbild (mapSvg / <image>) enthalten — embedBg steuert nur die iframe-Hülle, nicht die Redaktions-Grafik in der Karte.

/embed/centerplan?slug=palais-vest&chrome=minimal&detailMode=parent&hideHeader=1&partnerOrigin={v0Url}

Nur Karte: chrome=plan oder showFloors=0&showViewMode=0.

Beispiel v0 (eigenes Modal):

<iframe src={`${cockpitOrigin}/embed/centerplan?slug=${slug}&partnerOrigin=${encodeURIComponent(v0Origin)}&detailMode=parent&chrome=minimal&hideHeader=1`} />
window.addEventListener('message', (e) => {
if (e.origin !== cockpitOrigin || e.data?.type !== 'cockpit:centerplan:open-detail') return;
openV0ShopModal(e.data.poi);
});

postMessage-Vertrag (2D, 3D, Sidebar, Legende — identisch):

FeldWert
typecockpit:centerplan:open-detail
poi.typeshop, gastro, service, health-center, office
poi.item{ id, slug?, name?, title? }
poi.mapLocationIdMapLocation-UUID oder null
poi.svgIdSVG-Element-ID oder null
poi.floorNumberEtage (0=EG, -1=UG) oder null
poi.centerId / poi.centerSlugCenter-Kontext
Ziel-OriginpartnerOrigin aus Embed-URL (Fallback *)

3D-Klicks normalisieren SVG-IDs intern auf MapLocation-UUIDs (wie Sidebar).

So testen (Nachbau in v0):

  1. Plan öffnen → Shop-Fläche klicken → Sheet/Modal mit korrektem Namen.
  2. „Shop-Details“ → landet auf v0-Route /shops/…, nicht auf Cockpit-Domain.
  3. ?shop={uuid} in URL → gleicher Shop wird geöffnet.

So testen (Embed):

  1. v0-Seite mit iframe öffnen → Plan sichtbar, kein Cockpit-Header (default hideHeader=1).
  2. detailMode=parent → Klick auf 2D- oder 3D-Fläche postet POI-Payload; v0-Modal öffnet sich.
  3. detailMode=cockpit&shopDetailPath=/shops/{slug} → Cockpit-Modal, „Link“ zeigt auf v0-Origin.

Referenz-Code (Monorepo): apps/center-website/app/[slug]/wayfinding/wayfinding-page-client.tsx, packages/wayfinding (InteractiveFloorPlan, buildSvgIdToLocationMap).

Redaktion / Instructions: v0 + Cockpit Teil F + Centerplan-Embed mit eigenem Panel + Chat-Prompt „Centerplan — Chat-Prompt“.


Was: Gedruckte QR-Aufkleber (Service, Shop, Parkplatz, NOW!/2-go, externe Partner-URLs). Nicht verwechseln mit Handoff (Kiosk → Handy, POST /api/public/handoff-sessions).

Warum: v0 soll Redirect-Regeln nicht selbst nachbauen — Ziel kommt serverseitig aus Cockpit (targetType, redirectMode, targetPath, externalUrl).

Wer: v0 Signage/Companion-Projekt (Route /companion/qr/[qrCodeId]), Center-Website (/qr/[qrCodeId]), Redaktion (QR-Management).

Endpoints (öffentlich, CORS *)

Hint (QR_API_HINTS)MethodeZweck
qrResolveGetGET /api/public/qr-codes/{qrCodeId}/resolveZiel auflösen ohne Tracking
qrScanPostPOST /api/public/qr-codes/{qrCodeId}/scanTrack + Resolve (bevorzugt für v0)
qrTrackPostPOST /api/qr-codes/{qrCodeId}/trackNur Scan zählen
qrLookupGetGET /api/qr-codes?qrCodeId={qrCodeId}Legacy-Lookup (unvollständig — Resolve bevorzugen)

Query/Body optional: centerId, sessionKey (werden bei redirectMode=companion an finalRedirectPath angehängt).

Response data (Resolve / Scan)

FeldBedeutung
redirectModecompanion | website | external
finalRedirectPathRelativer Pfad inkl. Query (centerId, sessionKey) bei Companion
finalRedirectUrlAbsolute https://-URL nur bei external
entryUrlStabile URL fürs QR-Bild (v0-aware: resolvePublicFrontendChannels)
targetType / targetIdPersistiertes Ziel (shop, service, parking, 2-go, …)

v0-Redirect

redirectModeClient
companionrouter.push(finalRedirectPath)
websitePfad auf Website-Origin (2-go: /to-go/…)
externalwindow.location.href = finalRedirectUrl (nicht router.push)

apiHints-Quellen

  • GET …/public-visitor-surfacedata.apiHints + data.kiosk.touchscreens[] (floorNumber pro Standort)
  • GET …/signage-configsignageConfig.touchscreens[] + apiHints.touchscreensGet (Kiosk-Hauptquelle)
  • GET /api/touchscreens?centerId={uuid} — gleiche Felder, CORS offen
  • Frontend-Kanäle-Payload (Companion/Kiosk) inkl. touchscreensGet in apiHints
  • data.v0Integration.kiosk — Kurz-Flow Etage/Handoff in public-visitor-surface
  • data.v0Integration.locationQr — Standort-QR (nicht Handoff)

So testen

  1. QR im Dashboard anlegen → entryUrl in API-Response prüfen (v0-Companion-URL wenn Kanal vercel).
  2. GET /api/public/qr-codes/{id}/resolveredirectMode + finalRedirectPath stimmen.
  3. POST /api/public/qr-codes/{id}/scanscanCount steigt, gleiche Resolve-Felder.
  4. v0 /companion/qr/[id] → Redirect ohne eigenen switch.

Optional / später (P1-Rest)

ThemaHinweis
OpenAPI / Schema-DateiMaschinenlesbarer Vertrag
Dedizierte …/construction-diaryFalls 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, …/jobs?featured=true, …/services?publicWebsite=true&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, …). **Center-Öffnungszeiten:** data.center.openingHours, data.center.openingHoursNote, data.center.specialDays. 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:
- **Center-Stammdaten:** `GET …/public-visitor-surface` → `data.center.openingHours`, `data.center.openingHoursNote`, `data.center.specialDays` (primäre Quelle für v0/externe Sites).
- **Shops/Filialen/Services:** Feld `openingHours` in den jeweiligen Listen-Endpunkten. 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.
- **Sicherheit (Prompt Injection):** Der Client darf `assistant`-Nachrichten im Verlauf mitschicken (Follow-ups). Das Dashboard wandelt sie serverseitig für das LLM in **Kontext-`user`-Nachrichten** um (Prefix `[Vorherige Assistenten-Antwort …]`) — Fake-Assistant-Injection wird so entkräftet. Angebots-/Event-Texte aus der DB sind **Fakten**, keine Befehle (System-Prompt). Regression: `pnpm test:prompt-injection` (optional `--live` mit `PROMPT_INJECTION_CENTER_SLUG`); **echter DB-Pfad:** `pnpm test:prompt-injection-db-offer` (legt Test-Angebot mit Injection-Text an, testet Chatbot, löscht wieder — Standard-Center: Demo Mall `852d6ab5-51a1-4e1d-a4fd-4bd156c0143d`).
- Optional stream: true — Antwort als Server-Sent Events (text/event-stream); sonst kompakte JSON-Antwort — beides in der Route unterstützt.
- HTTP 429 (Cockpit): message „Zu viele Anfragen…“ — kurz warten. HTTP 502 + code openai_rate_limit: OpenAI-Quota/RPM (IT: OPENAI_API_KEY auf Render), kein Dashboard-Kontingent pro Center.

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: /developer-guide/public-center-website-api-vertrag