# Your own product gallery: API + iframe

> Code example: load products via the REST API (Bearer API key), render your own gallery and drive the embedded Kreativsimulator on click – incl. a remote-controlled export button. Copy-paste, testable locally on localhost:8080.
>
> Human-readable version: https://virtualcampaign.dev/en/embed/gallery (German: https://virtualcampaign.dev/embed/gallery)
> iframe basics (URL params, postMessage API): https://virtualcampaign.dev/embed.md
> REST API reference: https://api.virtualcampaign.dev/v1/docs
> This file is auto-generated from the live docs on every build — it is always current.

You build the gallery, we deliver the products. This example loads your tenant's products via the REST API, shows them as your own gallery and switches the embedded iframe to the product on click. A custom export button next to it renders the MP4 – all in a few lines of JavaScript, no framework, copy-paste, testable locally.

## Step 1: Requirements

**An API key** for the REST API (`Authorization: Bearer …`). To try it out you may use the public demo key below – it only reads the demo products. You get your own key with your tenant.

**A whitelisted domain** for the iframe (`frame-ancestors`, see [iframe guide, step 1](/en/embed)). For the demo tenant `http://localhost:8080` is whitelisted as well – so you can test locally before your domain goes live.

**Public demo API key (demo tenant only, read-only):** `vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e`

The demo key can be rotated at any time. In production, don't put a key into public frontend code if it can do more than read products – pass the product list through your own backend instead.

```bash
curl -H "Authorization: Bearer vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e" \
  "https://api.virtualcampaign.dev/v1/products?limit=50"
```

**Testing locally: `http://localhost:8080`** — **Exactly this port:** `http://localhost:8080` is whitelisted – not `127.0.0.1`, not another port. On any other origin the iframe stays blank (the gallery still loads, because the API is reachable from anywhere).

**CORS is open:** the API answers with `Access-Control-Allow-Origin: *` and allows the `Authorization` header – a `fetch` straight from the browser works, no proxy needed.

## Step 2: Load products via the API

`GET /v1/products` returns every product the API key may see – paginated (`limit`/`offset`, max 200), optionally filtered (`?type=film|bild|clip`, `?search=`). The full reference is in the [API documentation](https://api.virtualcampaign.dev/v1/docs#tag/products/GET/v1/products).

```js
const API = "https://api.virtualcampaign.dev";
const API_KEY = "vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e"; // demo key – replace with your own
const SIMULATOR = "https://demo.kreativsimulator.com/";      // your tenant: https://<tenant>.kreativsimulator.com/

// GET /v1/products → { products: [...], total, limit, offset, hasMore }
async function loadProducts() {
  const res = await fetch(API + "/v1/products?limit=50", {
    headers: { Authorization: "Bearer " + API_KEY },
  });
  if (!res.ok) throw new Error("API error " + res.status);
  const data = await res.json();
  return data.products; // [{ productCode, productName, productType, thumbnailUrl, placeholders, … }]
}

loadProducts().then((products) => console.log(products));
```

### The fields you need for a gallery

| Field | Meaning |
| --- | --- |
| `productCode` | The code the iframe expects for `setProduct` – the key between API and simulator. |
| `productName` | Display name for the card. |
| `productType` | `film` · `clip` · `bild` (photo product with Ken-Burns effects). |
| `durationSek` | Length in seconds – e.g. as a badge on the card. |
| `thumbnailUrl` | Relative URL of the preview image (`/v1/products/<code>/thumbnail`). Available **without** an API key – use it directly in `<img src>`; `?res=ldpi\|mdpi\|hdpi` picks the size. |
| `placeholders[]` | `loaderName` + `aspectRatio` per poster area – for your own upload fields (see `setCreative`). |

The `thumbnailUrl` is relative – prefix it with `https://api.virtualcampaign.dev`. The endpoint redirects (302) to the actual JPEG; the browser follows automatically.

## Step 3: Build the gallery, drive the iframe

Each product becomes a card with preview image and name. A click sends `setProduct` with the `productCode` to the iframe. The iframe runs in kiosk mode (`?embed=kiosk`): canvas only, your gallery is the UI.

Two details make the gallery robust: the cards stay **disabled until the `ready` event** arrives (before that the iframe accepts no commands). And only cards whose code the iframe reported in `ready.products` become active – the active card is highlighted via `productChanged`.

```html
<!-- iframe on the left, your gallery on the right -->
<div style="display:grid; grid-template-columns: 2fr 1fr; gap: 16px;">
  <iframe id="ks" src="https://demo.kreativsimulator.com/?embed=kiosk"
          title="Kreativsimulator" style="width:100%; aspect-ratio:16/9; border:0"
          allow="fullscreen"></iframe>
  <div id="gallery" style="display:grid; gap:8px; align-content:start"></div>
</div>

<script>
  const API = "https://api.virtualcampaign.dev";
  const API_KEY = "vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e"; // demo key – replace with your own
  const SIMULATOR = "https://demo.kreativsimulator.com/";      // your tenant: https://<tenant>.kreativsimulator.com/

  const iframe = document.getElementById("ks");
  const send = (msg) => iframe.contentWindow.postMessage(msg, "*");
  const gallery = document.getElementById("gallery");
  let simulatorCodes = null; // product codes the iframe knows (from the ready event)

  // 1) Build one card per API product. Cards stay disabled until the iframe is ready.
  function renderGallery(products) {
    gallery.innerHTML = "";
    for (const p of products) {
      const card = document.createElement("button");
      card.type = "button";
      card.dataset.code = p.productCode;
      card.disabled = true;
      card.style.cssText = "display:flex; gap:10px; align-items:center; text-align:left; padding:6px; border:2px solid #ddd; border-radius:8px; background:#fff; cursor:pointer";
      const img = document.createElement("img");
      img.src = API + p.thumbnailUrl + "?res=mdpi"; // public, no key needed
      img.alt = "";
      img.style.cssText = "width:96px; aspect-ratio:16/9; object-fit:cover; border-radius:4px";
      const name = document.createElement("span");
      name.textContent = p.productName;
      card.append(img, name);
      // 2) Click → the iframe switches to this product.
      card.onclick = () => send({ type: "kreativsimulator:setProduct", code: p.productCode });
      gallery.appendChild(card);
    }
    syncCards();
  }

  // Enable only cards the iframe can show; highlight the active one.
  function syncCards(activeCode) {
    for (const card of gallery.children) {
      const known = !simulatorCodes || simulatorCodes.has(card.dataset.code);
      card.disabled = !simulatorCodes || !known;
      card.title = known ? "" : "Not available in this simulator tenant";
      card.style.borderColor = card.dataset.code === activeCode ? "#ff5722" : "#ddd";
    }
  }

  // 3) Events from the iframe: ALWAYS filter on source === "kreativsimulator".
  window.addEventListener("message", (e) => {
    const m = e.data;
    if (!m || m.source !== "kreativsimulator") return;
    // Canvas shape: ready/productChanged carry `render`, stageChanged carries it
    // itself. A photo product is NOT 16:9 — follow it, then there is no letterbox.
    const r = m.type === "stageChanged" ? m : m.render;
    if (r) iframe.style.aspectRatio = r.width + " / " + r.height;
    if (m.type === "ready") {
      simulatorCodes = new Set(m.products.map((p) => p.code));
      syncCards(m.product);
    }
    if (m.type === "productChanged") syncCards(m.code);
    if (m.type === "error") console.warn("Kreativsimulator:", m.message);
  });

  fetch(API + "/v1/products?limit=50", { headers: { Authorization: "Bearer " + API_KEY } })
    .then((r) => r.json())
    .then((data) => renderGallery(data.products))
    .catch((err) => (gallery.textContent = "Could not load products: " + err.message));
</script>
```

- **Ordering:** send creatives via `setCreative` only after `productChanged` arrived for the new product – a product switch resets all creatives.
- **Preview image:** `<img>` needs no key. Only the product *list* needs the Bearer header.
- **Insert names safely:** set product names with `textContent`, not `innerHTML` – as in the example.
- **Follow the canvas shape:** not every product is 16:9 – photo products use the aspect ratio of the photo. `ready` and `productChanged` carry a `render` block (`width`/`height`/`aspectRatio`), the `stageChanged` event carries the same fields directly. Feed it into the `aspect-ratio` of your iframe and no letterbox is left – that is what the example below does.

## Step 4: Your own thumbnails instead of API thumbnails

Want to show your own product images – your photos, your design, your host? Then you don't need the API at runtime at all. A small list in your code maps each image to its **`productCode`**; the click sends `setProduct` with exactly that code. Everything else stays the same.

Get the code once: from `GET /v1/products` (field `productCode`), from the iframe's `ready` event (`products[].code`) or from the VirtualCampaign UI.

```html
<!-- Your own images (hosted by you) mapped to product codes. -->
<div style="display:grid; grid-template-columns: 2fr 1fr; gap: 16px;">
  <iframe id="ks" src="https://demo.kreativsimulator.com/?embed=kiosk"
          title="Kreativsimulator" style="width:100%; aspect-ratio:16/9; border:0"
          allow="fullscreen"></iframe>
  <div id="gallery" style="display:grid; gap:8px; align-content:start"></div>
</div>

<script>
  // The product code is the key. Take it from GET /v1/products (productCode),
  // from the iframe's `ready` event (products[].code) or from the VirtualCampaign UI.
  const MY_PRODUCTS = [
    { code: "29a5c31b", name: "City-Light München", image: "/img/citylight-muenchen.jpg" },
    { code: "2aff892d", name: "Billboard Bursa",    image: "/img/billboard-bursa.jpg" },
    { code: "e6bc7001", name: "ePanel Bahnhof",     image: "/img/epanel.jpg" },
  ];

  const iframe = document.getElementById("ks");
  const send = (msg) => iframe.contentWindow.postMessage(msg, "*");
  const gallery = document.getElementById("gallery");
  let simulatorCodes = null;

  for (const p of MY_PRODUCTS) {
    const card = document.createElement("button");
    card.type = "button";
    card.dataset.code = p.code;
    card.disabled = true; // until `ready`
    card.style.cssText = "display:flex; gap:10px; align-items:center; text-align:left; padding:6px; border:2px solid #ddd; border-radius:8px; background:#fff; cursor:pointer";
    const img = document.createElement("img");
    img.src = p.image; // your own file – any host, no CORS needed for <img>
    img.alt = "";
    img.style.cssText = "width:96px; aspect-ratio:16/9; object-fit:cover; border-radius:4px";
    const name = document.createElement("span");
    name.textContent = p.name;
    card.append(img, name);
    card.onclick = () => send({ type: "kreativsimulator:setProduct", code: p.code });
    gallery.appendChild(card);
  }

  function syncCards(activeCode) {
    for (const card of gallery.children) {
      const known = simulatorCodes && simulatorCodes.has(card.dataset.code);
      card.disabled = !known;
      card.title = simulatorCodes && !known ? "Unknown product code in this tenant" : "";
      card.style.borderColor = card.dataset.code === activeCode ? "#ff5722" : "#ddd";
    }
  }

  window.addEventListener("message", (e) => {
    const m = e.data;
    if (!m || m.source !== "kreativsimulator") return;
    // Canvas shape: ready/productChanged carry `render`, stageChanged carries it
    // itself. A photo product is NOT 16:9 — follow it, then there is no letterbox.
    const r = m.type === "stageChanged" ? m : m.render;
    if (r) iframe.style.aspectRatio = r.width + " / " + r.height;
    if (m.type === "ready") {
      simulatorCodes = new Set(m.products.map((p) => p.code));
      syncCards(m.product);
    }
    if (m.type === "productChanged") syncCards(m.code);
    if (m.type === "error") console.warn("Kreativsimulator:", m.message);
  });
</script>
```

- **Your own images need no CORS** – they go into an `<img>` on your page, not into the iframe. Only creatives for `setCreative` need CORS.
- **Wrong code?** Cards whose code the iframe doesn't report in `ready.products` stay disabled (tooltip). Typos show up immediately.
- **Mixing works too:** load the list from the API and only swap the image URL for your own (map `productCode → image`).

## Step 5: Remote-control the export button

The built-in export button is hidden in kiosk mode – export still works via postMessage. Your own button sends `export` with `width`/`height`; the resolutions come from `ready.render.presets`. A second button saves a still image (JPG) via `exportImage`.

```html
<select id="res"></select>
<button id="export">Export MP4</button>
<button id="still">Save image (JPG)</button>

<script>
  const P = "kreativsimulator:";
  const res = document.getElementById("res");

  // Fill the resolution picker from the presets the iframe reports in `ready`.
  window.addEventListener("message", (e) => {
    const m = e.data;
    if (!m || m.source !== "kreativsimulator" || m.type !== "ready") return;
    res.innerHTML = "";
    for (const r of m.render.presets) {
      const o = document.createElement("option");
      o.value = r.width + "x" + r.height;
      o.textContent = r.label;
      o.selected = r.width === m.render.width; // native resolution preselected
      res.appendChild(o);
    }
  });

  const size = () => res.value.split("x").map(Number);

  // Export works even when the built-in export button is hidden (?embed=kiosk).
  document.getElementById("export").onclick = () => {
    const [width, height] = size();
    send({ type: P + "export", filename: "kreation", width, height });
  };
  // Still image of the current frame (photo products: the whole photo).
  document.getElementById("still").onclick = () => {
    const [width, height] = size();
    send({ type: P + "exportImage", filename: "kreation", width, height });
  };
</script>
```

- **Demo tenant:** every export carries the “Vorschau” (preview) watermark – cannot be disabled. On your tenant it is off (or enabled on purpose via `watermark: true` / `?watermark=1`).
- **The download goes to the user** (browser download from the iframe) – your page doesn't receive the file. For server-side renders use the Orders API.
- **A specific frame as image:** `pause`/`seek` first, then `exportImage`.

## Step 6: Notice dialog before the download

Often the user has to confirm something before the download – e.g. a copyright notice. The pattern: your export button first opens a native `<dialog>`; only **“Confirm”** sends `export` or `exportImage` to the iframe. “Cancel”, the ×, <kbd>Esc</kbd> or a click outside send nothing.

The function `confirmThen(action)` remembers the intended action and runs it on the form's `submit` event – i.e. on “Confirm” by click or Enter. Text, colors and buttons are yours – the example uses the VirtualCampaign look (dark, orange); adapt it to your site.

```html
<!-- Notice dialog — shown before every download. Wording is yours. -->
<dialog id="notice" class="notice">
  <form method="dialog">
    <button type="button" class="notice-close" aria-label="Schließen" data-cancel>&times;</button>
    <h2>Hinweis</h2>
    <p>
      Die erstellte Simulation ist urheberrechtlich geschützt. Sie darf nur intern von Ihnen
      bzw. bei Agenturen auch gegenüber (potentiellen) Kunden zu Präsentationszwecken genutzt
      werden. Eine darüber hinausgehende Nutzung, insbesondere eine Veröffentlichung
      (Website, Social Media etc.), ist nicht gestattet.
    </p>
    <div class="notice-actions">
      <button type="button" class="notice-secondary" data-cancel>Abbrechen</button>
      <button type="submit" value="confirm" class="notice-primary">Bestätigen &rarr;</button>
    </div>
  </form>
</dialog>

<style>
/* Notice dialog — dark, orange accent (matches the VirtualCampaign look) */
.notice { border: 1px solid #2a2a2a; border-radius: 16px; background: #141414; color: #eee;
          padding: 0; margin: auto; width: min(640px, calc(100vw - 32px)); box-shadow: 0 30px 80px rgba(0,0,0,.6); }
.notice::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px); }
.notice form { position: relative; padding: 32px 36px 28px; margin: 0; }
.notice h2 { margin: 0 0 14px; font-size: 28px; font-weight: 600; letter-spacing: -.01em; }
.notice p { margin: 0 0 26px; color: #b5b5b5; line-height: 1.65; font-size: 15px; }
.notice-actions { display: flex; justify-content: flex-end; gap: 10px; }
.notice-primary { background: #ff5722; color: #fff; border: 0; border-radius: 999px; padding: 12px 24px;
                  font: inherit; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; cursor: pointer; }
.notice-primary:hover { background: #e64a19; }
.notice-secondary { background: transparent; color: #b5b5b5; border: 1px solid #333; border-radius: 999px;
                    padding: 12px 20px; font: inherit; cursor: pointer; }
.notice-secondary:hover { color: #fff; border-color: #666; }
.notice-close { position: absolute; top: 14px; right: 16px; background: none; border: 0; color: #ff5722;
                font-size: 26px; line-height: 1; cursor: pointer; }
</style>

<script>
  // Ask first, download afterwards: confirmThen(fn) opens the dialog and runs fn
  // only when the user confirms. Works for export (MP4) and exportImage (JPG) alike.
  const notice = document.getElementById("notice");
  let pendingAction = null;
  function confirmThen(action) {
    pendingAction = action;
    notice.showModal();
  }
  // "Bestätigen" submits the <form method="dialog"> (click or Enter) → run the action.
  notice.querySelector("form").addEventListener("submit", () => {
    if (pendingAction) pendingAction();
    pendingAction = null;
  });
  // "Abbrechen" / × / Esc just close the dialog — nothing is sent.
  notice.querySelectorAll("[data-cancel]").forEach((b) => (b.onclick = () => notice.close()));
  notice.addEventListener("cancel", () => (pendingAction = null));

  // Usage: wrap the export calls (send() and P from the gallery example).
  document.getElementById("export").onclick = () =>
    confirmThen(() => send({ type: P + "export", filename: "kreation", width: 1280, height: 720 }));
  document.getElementById("still").onclick = () =>
    confirmThen(() => send({ type: P + "exportImage", filename: "kreation", width: 1280, height: 720 }));
</script>
```

- **No framework needed:** `<dialog>` with `showModal()` brings focus trapping, <kbd>Esc</kbd> and the backdrop – in every current browser.
- **Ask only once?** After the first “Confirm” set e.g. `sessionStorage.setItem("noticeOk", "1")` and run the action directly in `confirmThen` when the value is set.
- **The export still runs in the iframe:** the dialog lives on your page, the download comes from the iframe as usual (demo tenant: watermarked).

## Step 7: Complete example (one file)

Everything in one `index.html`: iframe on the left, gallery on the right, export bar with notice dialog below. Save it, serve it locally on port 8080, open it – done. To adapt it, change the three constants at the top of the script.

1. Save the file as `index.html`.
2. Replace `API_KEY` and `SIMULATOR` with your values (the demo values work right away).
3. Serve it locally at `http://localhost:8080` (step 8) or publish it on your whitelisted domain.

```html
<!doctype html>
<html lang="de">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Kreativsimulator – Produkt-Galerie</title>
  <style>
    body { margin: 0; font-family: system-ui, sans-serif; background: #111; color: #eee; }
    .layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); gap: 16px; padding: 16px; }
    @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
    iframe { width: 100%; aspect-ratio: 16 / 9; border: 0; background: #000; border-radius: 8px; }
    #gallery { display: grid; gap: 8px; align-content: start; }
    .card { display: flex; gap: 10px; align-items: center; text-align: left; padding: 6px;
            border: 2px solid #333; border-radius: 8px; background: #1b1b1b; color: #eee; cursor: pointer; }
    .card:disabled { opacity: .4; cursor: default; }
    .card.active { border-color: #ff5722; }
    .card img { width: 96px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 4px; background: #000; }
    .bar { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
    button, select { font: inherit; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #222; color: #eee; }
    button:not(:disabled):hover { border-color: #ff5722; }
    #status { font-size: 12px; color: #999; margin-left: auto; }
    /* Notice dialog — dark, orange accent (matches the VirtualCampaign look) */
    .notice { border: 1px solid #2a2a2a; border-radius: 16px; background: #141414; color: #eee;
              padding: 0; margin: auto; width: min(640px, calc(100vw - 32px)); box-shadow: 0 30px 80px rgba(0,0,0,.6); }
    .notice::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px); }
    .notice form { position: relative; padding: 32px 36px 28px; margin: 0; }
    .notice h2 { margin: 0 0 14px; font-size: 28px; font-weight: 600; letter-spacing: -.01em; }
    .notice p { margin: 0 0 26px; color: #b5b5b5; line-height: 1.65; font-size: 15px; }
    .notice-actions { display: flex; justify-content: flex-end; gap: 10px; }
    .notice-primary { background: #ff5722; color: #fff; border: 0; border-radius: 999px; padding: 12px 24px;
                      font: inherit; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; cursor: pointer; }
    .notice-primary:hover { background: #e64a19; }
    .notice-secondary { background: transparent; color: #b5b5b5; border: 1px solid #333; border-radius: 999px;
                        padding: 12px 20px; font: inherit; cursor: pointer; }
    .notice-secondary:hover { color: #fff; border-color: #666; }
    .notice-close { position: absolute; top: 14px; right: 16px; background: none; border: 0; color: #ff5722;
                    font-size: 26px; line-height: 1; cursor: pointer; }
  </style>
</head>
<body>
  <div class="layout">
    <div>
      <!-- Only the canvas: header, sidebar and the built-in export button are hidden. -->
      <iframe id="ks" src="https://demo.kreativsimulator.com/?embed=kiosk" title="Kreativsimulator" allow="fullscreen"></iframe>
      <div class="bar">
        <select id="res"></select>
        <button id="export" disabled>Export MP4</button>
        <button id="still" disabled>Save image (JPG)</button>
        <span id="status">Loading …</span>
      </div>
    </div>
    <div id="gallery"></div>
  </div>

  <!-- Notice dialog — shown before every download. Wording is yours. -->
  <dialog id="notice" class="notice">
    <form method="dialog">
      <button type="button" class="notice-close" aria-label="Schließen" data-cancel>&times;</button>
      <h2>Hinweis</h2>
      <p>
        Die erstellte Simulation ist urheberrechtlich geschützt. Sie darf nur intern von Ihnen
        bzw. bei Agenturen auch gegenüber (potentiellen) Kunden zu Präsentationszwecken genutzt
        werden. Eine darüber hinausgehende Nutzung, insbesondere eine Veröffentlichung
        (Website, Social Media etc.), ist nicht gestattet.
      </p>
      <div class="notice-actions">
        <button type="button" class="notice-secondary" data-cancel>Abbrechen</button>
        <button type="submit" value="confirm" class="notice-primary">Bestätigen &rarr;</button>
      </div>
    </form>
  </dialog>

  <script>
    const API = "https://api.virtualcampaign.dev";
    const API_KEY = "vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e"; // demo key – replace with your own
    const SIMULATOR = "https://demo.kreativsimulator.com/";      // your tenant: https://<tenant>.kreativsimulator.com/
    const P = "kreativsimulator:";

    const iframe = document.getElementById("ks");
    const gallery = document.getElementById("gallery");
    const res = document.getElementById("res");
    const status = document.getElementById("status");
    const exportBtn = document.getElementById("export");
    const stillBtn = document.getElementById("still");
    const send = (msg) => iframe.contentWindow.postMessage(msg, "*");
    let simulatorCodes = null;

    // ── 1. Gallery from the REST API ────────────────────────────────────
    async function loadProducts() {
      const r = await fetch(API + "/v1/products?limit=50", {
        headers: { Authorization: "Bearer " + API_KEY },
      });
      if (!r.ok) throw new Error("API error " + r.status);
      return (await r.json()).products;
    }

    function renderGallery(products) {
      gallery.innerHTML = "";
      for (const p of products) {
        const card = document.createElement("button");
        card.type = "button";
        card.className = "card";
        card.dataset.code = p.productCode;
        card.disabled = true; // until the iframe says `ready`
        const img = document.createElement("img");
        img.src = API + p.thumbnailUrl + "?res=mdpi";
        img.alt = "";
        const name = document.createElement("span");
        name.textContent = p.productName;
        card.append(img, name);
        card.onclick = () => send({ type: P + "setProduct", code: p.productCode });
        gallery.appendChild(card);
      }
      syncCards();
    }

    function syncCards(activeCode) {
      for (const card of gallery.children) {
        const known = simulatorCodes && simulatorCodes.has(card.dataset.code);
        card.disabled = !known;
        card.title = simulatorCodes && !known ? "Not available in this simulator tenant" : "";
        card.classList.toggle("active", card.dataset.code === activeCode);
      }
    }

    // ── 2. Events from the iframe ───────────────────────────────────────
    window.addEventListener("message", (e) => {
      const m = e.data;
      if (!m || m.source !== "kreativsimulator") return;
      // Canvas shape + export presets: ready/productChanged carry `render`,
      // stageChanged carries the fields itself. A photo product is NOT 16:9.
      const shape = m.type === "stageChanged" ? m : m.render;
      if (shape) {
        iframe.style.aspectRatio = shape.width + " / " + shape.height;
        res.innerHTML = "";
        for (const r of shape.presets) {
          const o = document.createElement("option");
          o.value = r.width + "x" + r.height;
          o.textContent = r.label;
          o.selected = r.width === shape.width;
          res.appendChild(o);
        }
      }
      if (m.type === "ready") {
        simulatorCodes = new Set(m.products.map((p) => p.code));
        exportBtn.disabled = stillBtn.disabled = false;
        syncCards(m.product);
        status.textContent = "Ready";
      }
      if (m.type === "productChanged") syncCards(m.code);
      if (m.type === "error") status.textContent = "Error: " + m.message;
    });

    // ── 3. Notice dialog + remote export (works with the built-in button hidden)
    // Ask first, download afterwards: confirmThen(fn) opens the dialog and runs fn
    // only when the user confirms. Works for export (MP4) and exportImage (JPG) alike.
    const notice = document.getElementById("notice");
    let pendingAction = null;
    function confirmThen(action) {
      pendingAction = action;
      notice.showModal();
    }
    // "Bestätigen" submits the <form method="dialog"> (click or Enter) → run the action.
    notice.querySelector("form").addEventListener("submit", () => {
      if (pendingAction) pendingAction();
      pendingAction = null;
    });
    // "Abbrechen" / × / Esc just close the dialog — nothing is sent.
    notice.querySelectorAll("[data-cancel]").forEach((b) => (b.onclick = () => notice.close()));
    notice.addEventListener("cancel", () => (pendingAction = null));

    const size = () => res.value.split("x").map(Number);
    exportBtn.onclick = () => {
      const [width, height] = size();
      confirmThen(() => send({ type: P + "export", filename: "kreation", width, height }));
    };
    stillBtn.onclick = () => {
      const [width, height] = size();
      confirmThen(() => send({ type: P + "exportImage", filename: "kreation", width, height }));
    };

    loadProducts()
      .then(renderGallery)
      .catch((err) => (status.textContent = "Could not load products: " + err.message));
  </script>
</body>
</html>
```

## Step 8: Test locally on localhost:8080

Any static web server will do. Opening the file directly by double-click (`file://`) does **not** work – the browser then has no origin and the iframe stays blank.

In the folder of index.html:

```bash
# Option A – Node (npx, no install)
npx serve -l 8080 .

# Option B – Python 3
python -m http.server 8080

# then open:  http://localhost:8080
```

### If something doesn't work

**The gallery loads, the iframe stays black/blank** — The origin is not whitelisted. Locally it must be exactly http://localhost:8080 (not 127.0.0.1, no other port, no file://). The console (F12) shows “Refused to frame … frame-ancestors”. For your domain: request whitelisting.

**API responds 401** — The Bearer key is missing or wrong/rotated. Check the header: Authorization: Bearer vc_… (with a space after Bearer).

**API responds 403 GROUP_REQUIRED** — The key is not assigned to a user group and therefore sees no products. Contact us – we assign it.

**Cards stay greyed out** — The ready event hasn't arrived yet (iframe loading or blocked, see above) – or the productCode is not active in the simulator tenant. The card then shows a tooltip.

**Preview image 404** — No thumbnail is stored for this product yet. Set a fallback in img-onerror (e.g. a placeholder color).

## Want your own API key and domain whitelisted?

Send us your domain(s) – you get your tenant, your API key and the whitelisting in one go.

Contact: https://virtualcampaign.dev/en/#contact
