- Rasterizer: playwright-core + system Chromium renders dom steps to PNG (1280x800, JavaScript disabled — snapshots stay inert). Cached permanently on the step (dom_html immutable). CHROMIUM_PATH configurable. - Video render now includes DOM steps as real frames (verified h264 1280x800, 0 skipped); guides now show rasterized screenshots for DOM steps - Branching: per-step branch hotspots (dashed, labeled) jumping to any target step; lead gates respected on branch jumps - Chapters: demo-level chapter menu in player bar - API: PATCH /api/demos/:id (name, chapters), branches on step patch, both in public JSON - MCP surface: 15 tools (+set_step_branches, +set_chapters) - Docker: chromium + fonts in runtime image, CHROMIUM_PATH preset
191 lines
7.3 KiB
JavaScript
191 lines
7.3 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);
|
|
|
|
(step.branches || []).forEach(function (br) {
|
|
var bh = document.createElement("div");
|
|
bh.className = "hotspot";
|
|
bh.style.left = br.x + "%"; bh.style.top = br.y + "%";
|
|
bh.style.width = br.w + "%"; bh.style.height = br.h + "%";
|
|
bh.style.borderStyle = "dashed";
|
|
if (br.label) bh.title = br.label;
|
|
bh.addEventListener("click", function () { jumpTo(br.target_step_id); });
|
|
snap.appendChild(bh);
|
|
});
|
|
|
|
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 jumpTo(stepId) {
|
|
var idx = state.demo.steps.findIndex(function (s) { return s.id === stepId; });
|
|
if (idx < 0) return;
|
|
var cur = state.demo.steps[state.i];
|
|
if (cur.lead_gate && cur.lead_gate.enabled && !gatePassed[cur.id]) {
|
|
showGate(cur, function () { jumpTo(stepId); });
|
|
return;
|
|
}
|
|
state.i = idx;
|
|
beacon("advance", stepId);
|
|
render();
|
|
}
|
|
|
|
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;
|
|
if (demo.chapters && demo.chapters.length) {
|
|
var sel = document.createElement("select");
|
|
sel.style.cssText = "border:1px solid #e2e8f0;border-radius:6px;padding:5px 8px;font:inherit;max-width:180px";
|
|
var opt0 = document.createElement("option");
|
|
opt0.textContent = "Chapters"; opt0.value = ""; sel.appendChild(opt0);
|
|
demo.chapters.forEach(function (c) {
|
|
var o = document.createElement("option");
|
|
o.value = c.step_id; o.textContent = c.title; sel.appendChild(o);
|
|
});
|
|
sel.addEventListener("change", function () { if (sel.value) { jumpTo(sel.value); sel.value = ""; } });
|
|
titleEl.parentNode.insertBefore(sel, titleEl.nextSibling);
|
|
}
|
|
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>";
|
|
});
|
|
})();
|