From 3806fa917b01ae9ae1f18544d22418a39c46aaae Mon Sep 17 00:00:00 2001 From: scot Date: Sat, 18 Jul 2026 06:35:48 +0000 Subject: [PATCH] v0.4.0-alpha: duplicate_demo, editor UI for branches/chapters, delete, personalized links - duplicate: REST + MCP tool + editor button. Deep copy with branch targets and chapter targets remapped into the copy; lead gates + raster cache carried; copy is always a private draft (no inherited share token). Verified: 3 steps / 2 chapters / 1 branch all internally consistent. - delete demo: REST + editor button; FK cascade verified (0 orphans) - Editor: draw-mode toggle (hotspot vs branch) with target-step picker and branch list; chapters manager panel; personalized-link generator; duplicate/delete on demo list - MCP surface: 16 tools - USER-GUIDE updated: new editor features + personalization prompt now uses duplicate_demo --- apps/api/src/mcp.ts | 49 ++++++++++++ apps/api/src/routes/demos.ts | 54 +++++++++++++ apps/editor/src/App.tsx | 148 ++++++++++++++++++++++++++++++----- apps/editor/src/api.ts | 10 ++- apps/editor/src/styles.css | 12 +++ docs/USER-GUIDE.md | 18 ++++- 6 files changed, 265 insertions(+), 26 deletions(-) diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts index 8a1e3bc..8874ff2 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -165,6 +165,55 @@ function buildServer(key: AuthedKey): McpServer { }, ); + s.tool( + "duplicate_demo", + "Duplicate a demo as a new draft ('Copy of ...'): all steps, hotspots, annotations, lead gates, branches (retargeted), and chapters (retargeted). Assets are shared. Use before personalizing for a specific prospect.", + { demo_id: z.string().uuid() }, + async ({ demo_id }) => { + const d = await pool.query("SELECT * FROM demos WHERE id=$1", [demo_id]); + if (!d.rowCount) return text({ error: "not found" }); + const src = d.rows[0]; + const steps = await pool.query("SELECT * FROM steps WHERE demo_id=$1 ORDER BY position", [demo_id]); + await pool.query("BEGIN"); + try { + const nd = await pool.query( + "INSERT INTO demos (name, capture_id, theme) VALUES ($1,$2,$3) RETURNING id", + [`Copy of ${src.name}`.slice(0, 200), src.capture_id, src.theme], + ); + const newId = nd.rows[0].id; + const idMap = new Map(); + for (const st of steps.rows) { + const ns = await pool.query( + `INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html, hotspot, annotation, lead_gate, raster_asset_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, + [newId, st.position, st.snapshot_type, st.asset_id, st.dom_html, st.hotspot, st.annotation, st.lead_gate, st.raster_asset_id], + ); + idMap.set(st.id, ns.rows[0].id); + } + for (const st of steps.rows) { + if (!st.branches) continue; + const remapped = (st.branches as { target_step_id: string }[]) + .map((b) => ({ ...b, target_step_id: idMap.get(b.target_step_id) })) + .filter((b) => b.target_step_id); + await pool.query("UPDATE steps SET branches=$2 WHERE id=$1", [ + idMap.get(st.id), remapped.length ? JSON.stringify(remapped) : null, + ]); + } + if (src.chapters) { + const ch = (src.chapters as { title: string; step_id: string }[]) + .map((c) => ({ ...c, step_id: idMap.get(c.step_id) })) + .filter((c) => c.step_id); + await pool.query("UPDATE demos SET chapters=$2 WHERE id=$1", [newId, ch.length ? JSON.stringify(ch) : null]); + } + await pool.query("COMMIT"); + return text({ id: newId, name: `Copy of ${src.name}`, steps: steps.rowCount }); + } catch (e) { + await pool.query("ROLLBACK"); + throw e; + } + }, + ); + s.tool( "set_step_branches", "Set branch hotspots on a step: extra clickable regions that jump to a target step (percent coordinates). Pass an empty array to clear.", diff --git a/apps/api/src/routes/demos.ts b/apps/api/src/routes/demos.ts index 3523c38..e4aae5a 100644 --- a/apps/api/src/routes/demos.ts +++ b/apps/api/src/routes/demos.ts @@ -144,6 +144,60 @@ export async function demoRoutes(app: FastifyInstance): Promise { return { ok: true }; }); + app.post("/api/demos/:id/duplicate", { preHandler: requireScope("author") }, async (req, reply) => { + const { id } = req.params as { id: string }; + const d = await pool.query("SELECT * FROM demos WHERE id=$1", [id]); + if (!d.rowCount) return reply.code(404).send({ error: "not found" }); + const src = d.rows[0]; + const steps = await pool.query("SELECT * FROM steps WHERE demo_id=$1 ORDER BY position", [id]); + + await pool.query("BEGIN"); + try { + const nd = await pool.query( + "INSERT INTO demos (name, capture_id, theme) VALUES ($1,$2,$3) RETURNING id", + [`Copy of ${src.name}`.slice(0, 200), src.capture_id, src.theme], + ); + const newId = nd.rows[0].id; + const idMap = new Map(); + for (const st of steps.rows) { + const ns = await pool.query( + `INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html, hotspot, annotation, lead_gate, raster_asset_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, + [newId, st.position, st.snapshot_type, st.asset_id, st.dom_html, st.hotspot, st.annotation, st.lead_gate, st.raster_asset_id], + ); + idMap.set(st.id, ns.rows[0].id); + } + // second pass: remap branch targets + for (const st of steps.rows) { + if (!st.branches) continue; + const remapped = (st.branches as { target_step_id: string }[]) + .map((b) => ({ ...b, target_step_id: idMap.get(b.target_step_id) })) + .filter((b) => b.target_step_id); + await pool.query("UPDATE steps SET branches=$2 WHERE id=$1", [ + idMap.get(st.id), remapped.length ? JSON.stringify(remapped) : null, + ]); + } + // remap chapters + if (src.chapters) { + const ch = (src.chapters as { title: string; step_id: string }[]) + .map((c) => ({ ...c, step_id: idMap.get(c.step_id) })) + .filter((c) => c.step_id); + await pool.query("UPDATE demos SET chapters=$2 WHERE id=$1", [newId, ch.length ? JSON.stringify(ch) : null]); + } + await pool.query("COMMIT"); + return { id: newId, name: `Copy of ${src.name}`.slice(0, 200), status: "draft", steps: steps.rowCount }; + } catch (e) { + await pool.query("ROLLBACK"); + throw e; + } + }); + + app.delete("/api/demos/:id", { preHandler: requireScope("author") }, async (req) => { + const { id } = req.params as { id: string }; + await pool.query("DELETE FROM demos WHERE id=$1", [id]); // steps/analytics/leads cascade + return { ok: true }; + }); + app.post("/api/demos/:id/reorder", { preHandler: requireScope("author") }, async (req, reply) => { const { id } = req.params as { id: string }; const body = z.object({ ordered_step_ids: z.array(z.string().uuid()).min(1) }).parse(req.body); diff --git a/apps/editor/src/App.tsx b/apps/editor/src/App.tsx index 9914a6e..2b0b8f5 100644 --- a/apps/editor/src/App.tsx +++ b/apps/editor/src/App.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { api, getKey, setKey, type Demo, type Step, type Hotspot } from "./api"; +import { api, getKey, setKey, type Demo, type Step, type Hotspot, type Branch, type Chapter } from "./api"; export function App(): React.ReactElement { const [key, setKeyState] = useState(getKey()); @@ -17,7 +17,7 @@ export function App(): React.ReactElement { }, [key, refreshList]); const openDemo = (id: string) => - api.getDemo(id).then((d) => { setCurrent(d); setStepIdx(0); setErr(""); }).catch((e) => setErr(String(e))); + api.getDemo(id).then((d) => { setCurrent(d); setStepIdx((i) => Math.min(i, Math.max(0, d.steps.length - 1))); setErr(""); }).catch((e) => setErr(String(e))); const reload = () => current && openDemo(current.id); @@ -47,6 +47,9 @@ export function App(): React.ReactElement { {d.name} {d.status} {d.share_token && e.stopPropagation()}>view ↗} + + + ))} {!demos.length &&
  • No demos yet — create one, then add screenshots as steps (or use the capture extension).
  • } @@ -81,6 +84,9 @@ export function App(): React.ReactElement { reload(); }} /> + + + @@ -92,25 +98,80 @@ export function App(): React.ReactElement { navigator.clipboard.writeText(code); alert("Embed code copied:\n\n" + code); }}>Copy embed code )} + {current.share_token && ( + + )}
    {err &&
    {err}
    } - {step ? :
    Add a step to begin.
    } + {step ? :
    Add a step to begin.
    }
    ); } -function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): React.ReactElement { +function ChaptersPanel({ demo, onSaved }: { demo: Demo & { steps: Step[] }; onSaved: () => void }): React.ReactElement { + const [chapters, setChapters] = useState(demo.chapters ?? []); + const [title, setTitle] = useState(""); + const [target, setTarget] = useState(demo.steps[0]?.id ?? ""); + + useEffect(() => { setChapters(demo.chapters ?? []); }, [demo.id, demo.chapters]); + + const save = (next: Chapter[]) => { + setChapters(next); + api.patchDemo(demo.id, { chapters: next.length ? next : null }).then(onSaved); + }; + + const stepNo = (id: string) => demo.steps.findIndex((s) => s.id === id) + 1; + + return ( +
    + Chapters ({chapters.length}) +
      + {chapters.map((c, i) => ( +
    • + {c.title} → Step {stepNo(c.step_id) || "?"} + +
    • + ))} +
    +
    + setTitle(e.target.value)} /> + + +
    +
    + ); +} + +type DrawMode = "hotspot" | "branch"; + +function StepCanvas({ step, allSteps, onSaved }: { step: Step; allSteps: Step[]; onSaved: () => void }): React.ReactElement { const ref = useRef(null); + const [mode, setMode] = useState("hotspot"); const [drag, setDrag] = useState<{ x: number; y: number } | null>(null); const [box, setBox] = useState(step.hotspot); + const [branches, setBranches] = useState(step.branches ?? []); + const [pendingBranch, setPendingBranch] = useState(null); + const [branchTarget, setBranchTarget] = useState(""); + const [branchLabel, setBranchLabel] = useState(""); const [title, setTitle] = useState(step.annotation?.title ?? ""); const [body, setBody] = useState(step.annotation?.body ?? ""); const [gated, setGated] = useState(!!step.lead_gate?.enabled); - useEffect(() => { setBox(step.hotspot); setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); setGated(!!step.lead_gate?.enabled); }, [step.id]); + useEffect(() => { + setBox(step.hotspot); setBranches(step.branches ?? []); setPendingBranch(null); + setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); + setGated(!!step.lead_gate?.enabled); setMode("hotspot"); + }, [step.id]); const pct = (e: React.MouseEvent): { x: number; y: number } => { const r = ref.current!.getBoundingClientRect(); @@ -120,9 +181,27 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea }; }; + const stepNo = (id: string) => allSteps.findIndex((s) => s.id === id) + 1; + const otherSteps = allSteps.filter((s) => s.id !== step.id); + + const saveStep = () => + api.patchStep(step.id, { + hotspot: box, + annotation: title || body ? { title, body } : null, + branches: branches.length ? branches : null, + lead_gate: gated ? { enabled: true, fields: [ + { name: "name", label: "Your name", required: true }, + { name: "email", label: "Work email", required: true }, + ] } : null, + }).then(onSaved); + return (
    -

    Drag on the snapshot to draw the hotspot. Click Save when done.

    +
    + Draw: + + +
    void }): Rea onMouseMove={(e) => { if (!drag) return; const p = pct(e); - setBox({ + const b = { x: Math.min(drag.x, p.x), y: Math.min(drag.y, p.y), w: Math.abs(p.x - drag.x), h: Math.abs(p.y - drag.y), - }); + }; + if (mode === "hotspot") setBox(b); else setPendingBranch(b); }} onMouseUp={() => setDrag(null)} > @@ -144,25 +224,51 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea ) : (
    DOM step (rendered in player)
    )} - {box && ( -
    - )} + {box &&
    } + {branches.map((b, i) => ( +
    + ))} + {pendingBranch &&
    }
    + + {mode === "branch" && ( +
    + {pendingBranch ? ( + <> + setBranchLabel(e.target.value)} /> + + + + + ) : ( + Drag on the snapshot to draw a branch region, then pick its target step. + )} +
    + )} + + {branches.length > 0 && ( +
      + {branches.map((b, i) => ( +
    • + {b.label || "Branch"} → Step {stepNo(b.target_step_id) || "?"} + +
    • + ))} +
    + )} +
    setTitle(e.target.value)} />