Zum Hauptinhalt springen

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

Wasiframe zeigt nur den interaktiven Plan; bei Klick sendet Cockpit postMessage an die v0-Seite. v0 lädt Shop-Daten und rendert eigenes Detail-Panel.
Warumv0 soll Design-DNA (Layout, Typo, Panel links/rechts) behalten — nicht das Cockpit-Modal im iframe.
Werv0 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

  1. detailMode=parent — sonst öffnet Cockpit im iframe sein eigenes Modal (detailMode=cockpit oder Default).
  2. partnerOrigin={v0Origin} — exakte v0-URL (Origin, ohne Pfad), URL-encoded.
  3. Listener in v0 auf cockpit:centerplan:open-detailevent.origin muss cockpitOrigin sein (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.


// 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)

FeldInhalt
typecockpit:centerplan:open-detail (exakt)
poi.typeshop, gastro, service, health-center, office
poi.item.idShop-/Service-UUID — Schlüssel für shopsById
poi.item.slugSEO-Slug (optional)
poi.item.nameAnzeigename
poi.mapLocationIdMapLocation-UUID (Kartenfläche)
poi.svgIdz. B. shop-56
poi.floorNumber0 = EG, -1 = UG

Gilt für 2D-Fläche, 3D-Fläche, Sidebar/Legende im iframe (wenn showSidebar=1).


So testen

  1. iframe-URL im Browser öffnen — Plan sichtbar, kein Cockpit-Header.
  2. v0-Seite: DevTools → Console → temporär window.addEventListener('message', e => console.log(e.origin, e.data)).
  3. 3D-Fläche klicken (nicht nur Sidebar) → Log: cockpit:centerplan:open-detail.
  4. Panel links zeigt OBI o. Ä. mit Logo und Zeiten.
  5. detailMode=parent in URL prüfen — fehlt es, erscheint Cockpit-Modal im iframe.

Typische Fehler

SymptomUrsacheFix
Cockpit-Modal im iframedetailMode fehlt oder cockpitdetailMode=parent
Kein postMessagefalscher Build / kein partnerOriginCenter-Website deployen; partnerOrigin setzen
Listener feuert nichtevent.origin falsch geprüftOrigin = cockpitOrigin (iframe-URL-Host)
Shop-Panel leerpoi.item.id nicht in shopsByIdShops-API laden; Filialen ggf. isShopLocation
Nur 2D, 3D stillälterer Embed vor Fix 0.1.100Center-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

Nutzungsstatistik: Seitenaufrufe werden anonymisiert erfasst. Im Umami-Dashboard nach diesem Pfad filtern: /content-creator-handbuch/v0-centerplan-embed-eigenes-panel