demo-platform/apps/api/public/player.js
scot a535e15253 v0.2.0-alpha: DOM capture, all three output slices, lead gates
- Extension DOM mode: auth-context serializer — inlines CSS + images as data
  URLs, freezes form state, masks [data-dp-mask], strips scripts (R3 client)
- API: extractDataUrls rewrites large inline resources into content-addressed
  assets (R3 server; verified 939KB inline -> 82-byte row + served asset)
- Lead-gate steps: config on step, player overlay form blocks advance,
  submits to leads endpoint (editor toggle + MCP set_lead_gate)
- Step-guide export (v2 slice): authed markdown + public HTML at /p/:token/guide
- Video export (v3 slice, v0): ffmpeg slideshow of image steps + optional
  narration mux; DOM rasterization deferred to Chromium service (R4)
- Editor: reorder arrows, embed-code copy, guide link, video export
- REST reorder endpoint; MCP surface now 13 tools (export_guide,
  set_lead_gate, render_video)
- ffmpeg added to runtime image

E2E verified incl. R3 extraction round-trip, lead_gate in public JSON,
guide renders, MCP agent exported guide + rendered video.
2026-07-18 05:27:58 +00:00

155 lines
5.8 KiB
JavaScript

/* Demo player. Renders image or dom steps with hotspot overlay; emits privacy-first beacons. */
(function () {
"use strict";
var token = location.pathname.split("/").pop();
var params = new URLSearchParams(location.search);
var viewerToken = params.get("v") || null; // R7: identity only via explicit link token
var state = { demo: null, i: 0 };
var snap = document.getElementById("snap");
var dots = document.getElementById("dots");
var titleEl = document.getElementById("title");
function beacon(event, stepId) {
if (!state.demo) return;
var payload = JSON.stringify({
demo_id: state.demo.demo_id,
step_id: stepId || null,
viewer_token: viewerToken,
event: event,
});
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/public/analytics", new Blob([payload], { type: "application/json" }));
} else {
fetch("/api/public/analytics", { method: "POST", headers: { "Content-Type": "application/json" }, body: payload });
}
}
function render() {
var step = state.demo.steps[state.i];
snap.innerHTML = "";
var container;
if (step.snapshot_type === "image") {
container = document.createElement("img");
container.src = "/assets/" + step.asset_id;
container.draggable = false;
} else {
container = document.createElement("iframe");
container.setAttribute("sandbox", ""); // R1/R2: fully inert document, no scripts
container.srcdoc = step.dom_html || "";
}
snap.appendChild(container);
if (step.hotspot) {
var h = document.createElement("div");
h.className = "hotspot";
h.style.left = step.hotspot.x + "%";
h.style.top = step.hotspot.y + "%";
h.style.width = step.hotspot.w + "%";
h.style.height = step.hotspot.h + "%";
h.addEventListener("click", next);
snap.appendChild(h);
if (step.annotation) {
var t = document.createElement("div");
t.className = "tooltip";
var below = step.hotspot.y < 70;
t.style.left = Math.min(step.hotspot.x, 70) + "%";
t.style.top = below ? (step.hotspot.y + step.hotspot.h + 2) + "%" : "auto";
if (!below) t.style.bottom = (100 - step.hotspot.y + 2) + "%";
t.innerHTML =
(step.annotation.title ? "<h3></h3>" : "") + (step.annotation.body ? "<p></p>" : "");
if (step.annotation.title) t.querySelector("h3").textContent = step.annotation.title;
if (step.annotation.body) t.querySelector("p").textContent = step.annotation.body;
snap.appendChild(t);
}
}
dots.innerHTML = "";
state.demo.steps.forEach(function (_, j) {
var d = document.createElement("div");
d.className = "dot" + (j === state.i ? " active" : "");
dots.appendChild(d);
});
}
var gatePassed = {};
function showGate(step, after) {
var overlay = document.createElement("div");
overlay.style.cssText = "position:absolute;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:10";
var card = document.createElement("form");
card.style.cssText = "background:#fff;border-radius:10px;padding:22px;max-width:320px;width:90%;display:flex;flex-direction:column;gap:10px;box-shadow:0 12px 40px rgba(15,23,42,.25)";
var h = document.createElement("h3"); h.textContent = "Continue watching"; card.appendChild(h);
(step.lead_gate.fields || []).forEach(function (fdef) {
var inp = document.createElement("input");
inp.name = fdef.name; inp.placeholder = fdef.label + (fdef.required ? " *" : "");
inp.required = !!fdef.required;
inp.style.cssText = "border:1px solid #e2e8f0;border-radius:6px;padding:8px 10px;font:inherit";
card.appendChild(inp);
});
var btn = document.createElement("button"); btn.textContent = "Continue"; btn.type = "submit";
btn.style.cssText = "background:#2563eb;color:#fff;border:none;border-radius:6px;padding:9px;font:inherit;cursor:pointer";
card.appendChild(btn);
card.addEventListener("submit", function (e) {
e.preventDefault();
var fields = {};
(step.lead_gate.fields || []).forEach(function (fdef) {
fields[fdef.name] = card.elements[fdef.name].value;
});
fetch("/api/public/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ demo_id: state.demo.demo_id, viewer_token: viewerToken, fields: fields }),
}).finally(function () {
gatePassed[step.id] = true;
overlay.remove();
after();
});
});
overlay.appendChild(card);
snap.appendChild(overlay);
}
function next() {
var cur = state.demo.steps[state.i];
if (cur.lead_gate && cur.lead_gate.enabled && !gatePassed[cur.id]) {
showGate(cur, next);
return;
}
if (state.i >= state.demo.steps.length - 1) {
beacon("complete", cur.id);
return;
}
state.i++;
beacon("advance", state.demo.steps[state.i].id);
render();
}
function prev() {
if (state.i === 0) return;
state.i--;
beacon("back", state.demo.steps[state.i].id);
render();
}
document.getElementById("next").addEventListener("click", next);
document.getElementById("prev").addEventListener("click", prev);
document.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") next();
if (e.key === "ArrowLeft") prev();
});
fetch("/api/public/demo/" + token)
.then(function (r) { if (!r.ok) throw new Error("not found"); return r.json(); })
.then(function (demo) {
state.demo = demo;
titleEl.textContent = demo.name;
document.title = demo.name;
beacon("view", demo.steps.length ? demo.steps[0].id : null);
render();
})
.catch(function () {
snap.innerHTML = "<div style='padding:40px;color:#64748b'>This demo is not available.</div>";
});
})();