v0 — Centerplan-Embed mit eigenem Shop-Panel
Für wen: v0-Agent, Entwicklerin/Entwickler am v0-Projekt, IT bei Embed-Integration.
Ziel: Klick auf Shop-Fläche (2D oder 3D) im iframe → v0 entscheidet die UI (Panel links, Sheet, Vollseite) — kein Cockpit-Modal im iframe.
Voraussetzung: Cockpit-Centerplan ist live; Center-Website-Deploy mit /embed/centerplan (ab cockpitOS 0.1.100).
Was / Warum / Wer / Wo
| Was | iframe zeigt nur den interaktiven Plan; bei Klick sendet Cockpit postMessage an die v0-Seite. v0 lädt Shop-Daten und rendert eigenes Detail-Panel. |
| Warum | v0 soll Design-DNA (Layout, Typo, Panel links/rechts) behalten — nicht das Cockpit-Modal im iframe. |
| Wer | v0 baut Layout + Listener; Cockpit liefert Plan + Events + Shop-API. |
| Wo (Cockpit-API) | GET …/centers/by-slug/{slug} → centerId; Shops: GET …/centers/{centerId}/shops?publicWebsite=true&status=Aktiv&limit=5000 |
| Wo (Embed) | {cockpitOrigin}/embed/centerplan?… — cockpitOrigin = customDomain oder https://{slug}.cockpit-os.de |
Kurz: die drei Pflicht-Regeln
detailMode=parent— sonst öffnet Cockpit im iframe sein eigenes Modal (detailMode=cockpitoder Default).partnerOrigin={v0Origin}— exakte v0-URL (Origin, ohne Pfad), URL-encoded.- Listener in v0 auf
cockpit:centerplan:open-detail—event.originmusscockpitOriginsein (iframe-Host), nicht die v0-URL.
Schritt 1 — Daten beim Seitenstart laden (Server)
// lib/cockpit/load-center.ts (Server Component)
const API = process.env.NEXT_PUBLIC_DASHBOARD_URL!; // https://dashboard.cockpit-os.de
export async function loadCenterContext(slug: string) {
const bySlug = await fetch(`${API}/api/centers/by-slug/${slug}`, { next: { revalidate: 60 } }).then(r => r.json());
const centerId = bySlug.id;
const surface = await fetch(`${API}/api/centers/${centerId}/public-visitor-surface`, { next: { revalidate: 60 } }).then(r => r.json());
const shopsRes = await fetch(
`${API}/api/centers/${centerId}/shops?publicWebsite=true&status=Aktiv&limit=5000`,
{ next: { revalidate: 60 } }
).then(r => r.json());
const cockpitOrigin =
surface.data?.center?.customDomain?.replace(/\/$/, '') ||
`https://${slug}.cockpit-os.de`;
return {
centerId,
slug,
cockpitOrigin,
shops: shopsRes.data ?? [],
centerplan: surface.data?.centerplan,
};
}
Shops einmal laden und per id indexieren — beim Klick braucht v0 keinen Extra-Request (optional nachladen für Frische).
Schritt 2 — iframe-URL bauen
function buildEmbedSrc(opts: {
cockpitOrigin: string;
slug: string;
v0Origin: string;
shopId?: string | null; // Deep-Link: Plan markiert Shop
}) {
const q = new URLSearchParams({
slug: opts.slug,
partnerOrigin: opts.v0Origin,
detailMode: 'parent',
hideHeader: '1',
chrome: 'minimal', // Etagen + 2D/3D im iframe
showSidebar: '0', // keine Cockpit-Shop-Liste — v0 hat eigenes Panel
showLegend: '0',
wayfinding: '0',
embedBg: 'transparent',
chromeGhost: '1',
});
if (opts.shopId) q.set('shop', opts.shopId);
return `${opts.cockpitOrigin.replace(/\/$/, '')}/embed/centerplan?${q}`;
}
Layout-Tipp: chrome=plan wenn v0 gar keine Cockpit-Controls will (nur Karte); Etagen/2D/3D dann optional als eigenes UI in v0 (ohne postMessage-Steuerung der Etage — nur Start-Etage per floorNumber=0).
Branding (optional): accent, accentFg, chromeBg, chromeRadius, hoverColor — siehe API-Vertrag Centerplan-Embed.
Schritt 3 — Layout: Panel links, Plan rechts
// components/centerplan/centerplan-with-panel.tsx
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
type Poi = {
type: 'shop' | 'gastro' | 'service' | 'health-center' | 'office';
item: { id: string; slug?: string | null; name?: string | null; title?: string | null };
mapLocationId?: string | null;
svgId?: string | null;
floorNumber?: number | null;
centerId: string;
centerSlug: string;
};
type ShopRow = {
id: string;
slug?: string | null;
name: string;
logo?: string;
phone?: string;
website?: string;
openingHours?: string | object;
description?: string;
category?: string;
};
export function CenterplanWithPanel(props: {
embedSrc: string;
cockpitOrigin: string;
shopsById: Record<string, ShopRow>;
}) {
const [selectedPoi, setSelectedPoi] = useState<Poi | null>(null);
const selectedShop = useMemo(() => {
if (!selectedPoi?.item?.id) return null;
return props.shopsById[selectedPoi.item.id] ?? null;
}, [selectedPoi, props.shopsById]);
const onMessage = useCallback(
(event: MessageEvent) => {
if (event.origin !== props.cockpitOrigin) return;
if (event.data?.type !== 'cockpit:centerplan:open-detail') return;
setSelectedPoi(event.data.poi as Poi);
},
[props.cockpitOrigin]
);
useEffect(() => {
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [onMessage]);
return (
<div className="grid min-h-[70vh] grid-cols-1 gap-4 lg:grid-cols-[minmax(280px,360px)_1fr]">
<aside className="rounded-xl border bg-card p-4">
{!selectedShop ? (
<p className="text-sm text-muted-foreground">
Shop auf dem Plan anklicken — Details erscheinen hier.
</p>
) : (
<ShopDetailPanel shop={selectedShop} poi={selectedPoi} />
)}
</aside>
<iframe
title="Centerplan"
src={props.embedSrc}
className="h-[70vh] w-full rounded-xl border-0"
allow="fullscreen"
/>
</div>
);
}
Schritt 4 — Shop-Detail im Panel (v0-Design)
function ShopDetailPanel({ shop, poi }: { shop: ShopRow; poi: Poi | null }) {
const hours = formatOpeningHours(shop.openingHours); // siehe unten
return (
<div className="space-y-4">
{shop.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={shop.logo} alt="" className="h-12 w-auto object-contain" />
) : null}
<div>
<h2 className="text-lg font-semibold">{shop.name}</h2>
{shop.category ? <p className="text-sm text-muted-foreground">{shop.category}</p> : null}
</div>
{hours ? <p className="text-sm">{hours}</p> : null}
{shop.phone ? <p className="text-sm">{shop.phone}</p> : null}
{shop.website ? (
<a href={shop.website} className="text-sm underline" target="_blank" rel="noopener noreferrer">
Website
</a>
) : null}
<a href={`/shops/${shop.slug || shop.id}`} className="inline-block text-sm font-medium underline">
Zur Shop-Seite
</a>
</div>
);
}
/** openingHours: JSON-String oder Objekt mit regularHours (MEC-Import) */
function formatOpeningHours(raw: unknown): string {
if (!raw) return '';
let o: any = raw;
if (typeof raw === 'string') {
try { o = JSON.parse(raw); } catch { return raw; }
}
const rh = o?.regularHours;
if (!rh || typeof rh !== 'object') return '';
const days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] as const;
const labels = ['Mo','Di','Mi','Do','Fr','Sa','So'];
return days
.map((d, i) => {
const slots = Array.isArray(rh[d]) ? rh[d] : rh[d] ? [rh[d]] : [];
const text = slots.map((s: { open?: string; close?: string }) =>
s?.open && s?.close ? `${s.open}–${s.close}` : null
).filter(Boolean).join(', ');
return text ? `${labels[i]}: ${text}` : null;
})
.filter(Boolean)
.join(' · ');
}
Medien-URLs: relative Pfade mit CDN-Basis prefixen (https://cockpitos.b-cdn.net) — siehe v0 Teil C.
postMessage-Vertrag (Referenz)
| Feld | Inhalt |
|---|---|
type | cockpit:centerplan:open-detail (exakt) |
poi.type | shop, gastro, service, health-center, office |
poi.item.id | Shop-/Service-UUID — Schlüssel für shopsById |
poi.item.slug | SEO-Slug (optional) |
poi.item.name | Anzeigename |
poi.mapLocationId | MapLocation-UUID (Kartenfläche) |
poi.svgId | z. B. shop-56 |
poi.floorNumber | 0 = EG, -1 = UG |
Gilt für 2D-Fläche, 3D-Fläche, Sidebar/Legende im iframe (wenn showSidebar=1).
So testen
- iframe-URL im Browser öffnen — Plan sichtbar, kein Cockpit-Header.
- v0-Seite: DevTools → Console → temporär
window.addEventListener('message', e => console.log(e.origin, e.data)). - 3D-Fläche klicken (nicht nur Sidebar) → Log:
cockpit:centerplan:open-detail. - Panel links zeigt OBI o. Ä. mit Logo und Zeiten.
detailMode=parentin URL prüfen — fehlt es, erscheint Cockpit-Modal im iframe.
Typische Fehler
| Symptom | Ursache | Fix |
|---|---|---|
| Cockpit-Modal im iframe | detailMode fehlt oder cockpit | detailMode=parent |
| Kein postMessage | falscher Build / kein partnerOrigin | Center-Website deployen; partnerOrigin setzen |
| Listener feuert nicht | event.origin falsch geprüft | Origin = cockpitOrigin (iframe-URL-Host) |
| Shop-Panel leer | poi.item.id nicht in shopsById | Shops-API laden; Filialen ggf. isShopLocation |
| Nur 2D, 3D still | älterer Embed vor Fix 0.1.100 | Center-Website aktualisieren |
Copy-Paste: v0-Agent-Prompt
In v0 Chat oder als Zusatz-Instruction (wenn Zeichenbudget reicht):
Centerplan-Embed mit eigenem Shop-Panel (Pflicht):
1) Server: by-slug → centerId; shops?publicWebsite=true&status=Aktiv&limit=5000; public-visitor-surface → cockpitOrigin = customDomain || https://{slug}.cockpit-os.de
2) iframe src = {cockpitOrigin}/embed/centerplan?slug={slug}&partnerOrigin={encodeURIComponent(v0Origin)}&detailMode=parent&hideHeader=1&chrome=minimal&showSidebar=0&showLegend=0&wayfinding=0&embedBg=transparent&chromeGhost=1
3) Layout: links festes Panel (Shop-Detail), rechts iframe min-h-[70vh]. KEIN Cockpit-Modal in v0 — detailMode=parent verhindert Modal im iframe; v0 rendert Detail.
4) Client useEffect: window.addEventListener('message', …). if (event.origin !== cockpitOrigin) return; if (event.data?.type !== 'cockpit:centerplan:open-detail') return; setSelectedPoi(event.data.poi); Shop aus shopsById[poi.item.id] anzeigen (Logo, openingHours regularHours parsen, Telefon, Link /shops/{slug|id}).
5) VERBOTEN: detailMode=cockpit für eigenes Panel; event.origin gegen v0-URL prüfen; Shop-Links auf cockpitOrigin/shops.
6) Test: 3D-Flächen-Klick muss postMessage liefern (nicht nur Sidebar).
Weiterlesen
- v0 + Cockpit — Instructions A–J (Teil F Embed)
- Öffentliche API — Centerplan-Embed
- Changelog 0.1.100 (3D-postMessage + Theme-Parameter)
Nutzungsstatistik: Seitenaufrufe werden anonymisiert erfasst. Im Umami-Dashboard nach diesem Pfad filtern: /content-creator-handbuch/v0-centerplan-embed-eigenes-panel