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
This commit is contained in:
parent
4333cd23ef
commit
3806fa917b
6 changed files with 265 additions and 26 deletions
|
|
@ -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<string, string>();
|
||||
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.",
|
||||
|
|
|
|||
|
|
@ -144,6 +144,60 @@ export async function demoRoutes(app: FastifyInstance): Promise<void> {
|
|||
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<string, string>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<strong>{d.name}</strong>
|
||||
<span className={`badge ${d.status}`}>{d.status}</span>
|
||||
{d.share_token && <a href={`/p/${d.share_token}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>view ↗</a>}
|
||||
<span className="spacer" />
|
||||
<button className="x" title="Duplicate" onClick={(e) => { e.stopPropagation(); api.duplicate(d.id).then(refreshList).catch((er) => setErr(String(er))); }}>⧉</button>
|
||||
<button className="x" title="Delete demo" onClick={(e) => { e.stopPropagation(); if (confirm(`Delete "${d.name}" and all its steps, analytics and leads?`)) api.deleteDemo(d.id).then(refreshList).catch((er) => setErr(String(er))); }}>×</button>
|
||||
</li>
|
||||
))}
|
||||
{!demos.length && <li className="empty">No demos yet — create one, then add screenshots as steps (or use the capture extension).</li>}
|
||||
|
|
@ -81,6 +84,9 @@ export function App(): React.ReactElement {
|
|||
reload();
|
||||
}} />
|
||||
</label>
|
||||
|
||||
<ChaptersPanel demo={current} onSaved={reload} />
|
||||
|
||||
<button className="publish" onClick={() => api.publish(current.id).then(({ share_token }) => { alert(`Published: ${location.origin}/p/${share_token}`); reload(); })}>
|
||||
{current.status === "published" ? "Republish" : "Publish"}
|
||||
</button>
|
||||
|
|
@ -92,25 +98,80 @@ export function App(): React.ReactElement {
|
|||
navigator.clipboard.writeText(code); alert("Embed code copied:\n\n" + code);
|
||||
}}>Copy embed code</button>
|
||||
)}
|
||||
{current.share_token && (
|
||||
<button onClick={() => {
|
||||
const v = prompt("Viewer token for this recipient (e.g. acme-corp):");
|
||||
if (!v) return;
|
||||
const link = `${location.origin}/p/${current.share_token}?v=${encodeURIComponent(v.trim())}`;
|
||||
navigator.clipboard.writeText(link); alert("Personalized link copied:\n\n" + link);
|
||||
}}>Copy personalized link</button>
|
||||
)}
|
||||
<button onClick={() => api.renderVideo(current.id).then((r) => alert(`Video rendered${r.skipped_dom_steps ? ` (${r.skipped_dom_steps} DOM steps skipped)` : ""}. Download: ${location.origin}${r.download}`)).catch((e) => alert(String(e)))}>Export video</button>
|
||||
</aside>
|
||||
<main>
|
||||
{err && <div className="err">{err}</div>}
|
||||
{step ? <StepCanvas step={step} onSaved={reload} /> : <div className="empty">Add a step to begin.</div>}
|
||||
{step ? <StepCanvas step={step} allSteps={current.steps} onSaved={reload} /> : <div className="empty">Add a step to begin.</div>}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Chapter[]>(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 (
|
||||
<details className="panel">
|
||||
<summary>Chapters ({chapters.length})</summary>
|
||||
<ul className="chapter-list">
|
||||
{chapters.map((c, i) => (
|
||||
<li key={i}>
|
||||
<span>{c.title} → Step {stepNo(c.step_id) || "?"}</span>
|
||||
<button className="x" onClick={() => save(chapters.filter((_, j) => j !== i))}>×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="chapter-add">
|
||||
<input placeholder="Chapter title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<select value={target} onChange={(e) => setTarget(e.target.value)}>
|
||||
{demo.steps.map((s, i) => <option key={s.id} value={s.id}>Step {i + 1}</option>)}
|
||||
</select>
|
||||
<button disabled={!title.trim() || !target} onClick={() => { save([...chapters, { title: title.trim(), step_id: target }]); setTitle(""); }}>Add</button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
type DrawMode = "hotspot" | "branch";
|
||||
|
||||
function StepCanvas({ step, allSteps, onSaved }: { step: Step; allSteps: Step[]; onSaved: () => void }): React.ReactElement {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [mode, setMode] = useState<DrawMode>("hotspot");
|
||||
const [drag, setDrag] = useState<{ x: number; y: number } | null>(null);
|
||||
const [box, setBox] = useState<Hotspot | null>(step.hotspot);
|
||||
const [branches, setBranches] = useState<Branch[]>(step.branches ?? []);
|
||||
const [pendingBranch, setPendingBranch] = useState<Hotspot | null>(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 (
|
||||
<div className="canvas-wrap">
|
||||
<p className="hint">Drag on the snapshot to draw the hotspot. Click Save when done.</p>
|
||||
<div className="mode-row">
|
||||
<span className="hint">Draw:</span>
|
||||
<label><input type="radio" checked={mode === "hotspot"} onChange={() => setMode("hotspot")} /> Next-step hotspot</label>
|
||||
<label><input type="radio" checked={mode === "branch"} onChange={() => setMode("branch")} /> Branch hotspot</label>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className="canvas"
|
||||
|
|
@ -130,12 +209,13 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => 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
|
|||
) : (
|
||||
<div className="dom-note">DOM step (rendered in player)</div>
|
||||
)}
|
||||
{box && (
|
||||
<div className="hotspot" style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }} />
|
||||
)}
|
||||
{box && <div className="hotspot" style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }} />}
|
||||
{branches.map((b, i) => (
|
||||
<div key={i} className="hotspot branch" title={b.label} style={{ left: `${b.x}%`, top: `${b.y}%`, width: `${b.w}%`, height: `${b.h}%` }} />
|
||||
))}
|
||||
{pendingBranch && <div className="hotspot branch pending" style={{ left: `${pendingBranch.x}%`, top: `${pendingBranch.y}%`, width: `${pendingBranch.w}%`, height: `${pendingBranch.h}%` }} />}
|
||||
</div>
|
||||
|
||||
{mode === "branch" && (
|
||||
<div className="branch-form">
|
||||
{pendingBranch ? (
|
||||
<>
|
||||
<input placeholder="Label (optional)" value={branchLabel} onChange={(e) => setBranchLabel(e.target.value)} />
|
||||
<select value={branchTarget} onChange={(e) => setBranchTarget(e.target.value)}>
|
||||
<option value="">Jump to…</option>
|
||||
{otherSteps.map((s) => <option key={s.id} value={s.id}>Step {stepNo(s.id)}</option>)}
|
||||
</select>
|
||||
<button disabled={!branchTarget} onClick={() => {
|
||||
setBranches([...branches, { ...pendingBranch, label: branchLabel.trim() || undefined, target_step_id: branchTarget }]);
|
||||
setPendingBranch(null); setBranchLabel(""); setBranchTarget("");
|
||||
}}>Add branch</button>
|
||||
<button className="ghost" onClick={() => setPendingBranch(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="hint">Drag on the snapshot to draw a branch region, then pick its target step.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branches.length > 0 && (
|
||||
<ul className="branch-list">
|
||||
{branches.map((b, i) => (
|
||||
<li key={i}>
|
||||
<span>{b.label || "Branch"} → Step {stepNo(b.target_step_id) || "?"}</span>
|
||||
<button className="x" onClick={() => setBranches(branches.filter((_, j) => j !== i))}>×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="ann">
|
||||
<input placeholder="Tooltip title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<textarea placeholder="Tooltip body" value={body} onChange={(e) => setBody(e.target.value)} rows={3} />
|
||||
<label className="gate"><input type="checkbox" checked={gated} onChange={(e) => setGated(e.target.checked)} /> Lead gate: require name + email before advancing past this step</label>
|
||||
<div className="row">
|
||||
<button onClick={() =>
|
||||
api.patchStep(step.id, {
|
||||
hotspot: box,
|
||||
annotation: title || body ? { title, body } : null,
|
||||
lead_gate: gated ? { enabled: true, fields: [
|
||||
{ name: "name", label: "Your name", required: true },
|
||||
{ name: "email", label: "Work email", required: true },
|
||||
] } : null,
|
||||
}).then(onSaved)
|
||||
}>Save step</button>
|
||||
<button onClick={saveStep}>Save step</button>
|
||||
<button className="ghost" onClick={() => setBox(null)}>Clear hotspot</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
|
|||
|
||||
export interface Hotspot { x: number; y: number; w: number; h: number; }
|
||||
export interface Annotation { title: string; body: string; }
|
||||
export interface Branch { x: number; y: number; w: number; h: number; label?: string; target_step_id: string; }
|
||||
export interface Chapter { title: string; step_id: string; }
|
||||
export interface LeadGate { enabled: boolean; fields: { name: string; label: string; required: boolean }[]; }
|
||||
export interface Step {
|
||||
id: string;
|
||||
|
|
@ -29,12 +31,14 @@ export interface Step {
|
|||
hotspot: Hotspot | null;
|
||||
annotation: Annotation | null;
|
||||
lead_gate?: LeadGate | null;
|
||||
branches?: Branch[] | null;
|
||||
}
|
||||
export interface Demo {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "draft" | "published";
|
||||
share_token: string | null;
|
||||
chapters?: Chapter[] | null;
|
||||
steps?: Step[];
|
||||
}
|
||||
|
||||
|
|
@ -42,10 +46,14 @@ export const api = {
|
|||
listDemos: () => req<Demo[]>("GET", "/api/demos"),
|
||||
getDemo: (id: string) => req<Demo & { steps: Step[] }>("GET", `/api/demos/${id}`),
|
||||
createDemo: (name: string) => req<Demo>("POST", "/api/demos", { name }),
|
||||
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position" | "lead_gate">>) =>
|
||||
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position" | "lead_gate" | "branches">>) =>
|
||||
req<Step>("PATCH", `/api/steps/${id}`, patch),
|
||||
deleteStep: (id: string) => req<{ ok: boolean }>("DELETE", `/api/steps/${id}`),
|
||||
publish: (id: string) => req<{ share_token: string; url: string }>("POST", `/api/demos/${id}/publish`),
|
||||
patchDemo: (id: string, patch: { name?: string; chapters?: Chapter[] | null }) =>
|
||||
req<Demo>("PATCH", `/api/demos/${id}`, patch),
|
||||
duplicate: (id: string) => req<{ id: string; name: string }>("POST", `/api/demos/${id}/duplicate`, {}),
|
||||
deleteDemo: (id: string) => req<{ ok: boolean }>("DELETE", `/api/demos/${id}`),
|
||||
reorder: (id: string, ids: string[]) => req<{ ok: boolean }>("POST", `/api/demos/${id}/reorder`, { ordered_step_ids: ids }),
|
||||
renderVideo: (id: string) => req<{ asset_id: string; download: string; skipped_dom_steps: number }>("POST", `/api/demos/${id}/video`, {}),
|
||||
addImageStep: async (demoId: string, file: File): Promise<Step> => {
|
||||
|
|
|
|||
|
|
@ -42,3 +42,15 @@ input,textarea { font:inherit; border:1px solid var(--line); border-radius:6px;
|
|||
.mini .x[disabled] { opacity:.3; cursor:default; }
|
||||
.gate { font-size:13px; color:var(--muted); display:flex; gap:8px; align-items:center; }
|
||||
.gate input { width:auto; }
|
||||
.spacer { flex:1; }
|
||||
.hotspot.branch { border-style:dashed; background:rgba(37,99,235,.05); }
|
||||
.hotspot.branch.pending { border-color:#d97706; }
|
||||
.mode-row { display:flex; gap:14px; align-items:center; margin-bottom:8px; font-size:13px; color:var(--muted); }
|
||||
.mode-row input { width:auto; }
|
||||
.branch-form { display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap; }
|
||||
.branch-form input, .branch-form select { width:auto; flex:1; min-width:120px; }
|
||||
.branch-list, .chapter-list { list-style:none; padding:0; margin:8px 0 0; display:flex; flex-direction:column; gap:4px; font-size:13px; }
|
||||
.branch-list li, .chapter-list li { display:flex; justify-content:space-between; align-items:center; background:#f8fafc; border:1px solid var(--line); border-radius:6px; padding:5px 10px; }
|
||||
.panel { border:1px solid var(--line); border-radius:6px; padding:8px 10px; }
|
||||
.panel summary { cursor:pointer; font-size:13px; color:var(--muted); }
|
||||
.chapter-add { display:flex; flex-direction:column; gap:6px; margin-top:8px; }
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ are the last line of defence.
|
|||
- **Lead gate** — tick the box on a step and viewers must leave name + email
|
||||
before advancing past it. Put it after you've shown value, not on step 1
|
||||
- **Reorder / delete** — arrows and × in the step list
|
||||
- **Branch hotspots** — switch Draw mode to "Branch", drag a region, pick the
|
||||
target step. Branches render dashed in the player and let viewers choose
|
||||
their own path
|
||||
- **Chapters** — the Chapters panel in the sidebar builds the jump menu shown
|
||||
in the player bar
|
||||
- **Duplicate / delete demos** — ⧉ and × on the demo list. Duplicate before
|
||||
personalizing for a prospect; the copy is always a private draft
|
||||
- **Personalized link** — one click generates a share link with a viewer token
|
||||
baked in
|
||||
- **Step guide** — auto-generated from your steps at `/p/<token>/guide`
|
||||
- **Export video** — renders an MP4 slideshow of all steps; add narration via
|
||||
your agent (§7, "Narrated video")
|
||||
|
|
@ -138,10 +147,11 @@ the agent can list them.
|
|||
> "Deep dive", "Getting started".
|
||||
|
||||
**Personalized prospect demo:**
|
||||
> Duplicate isn't available yet, so: take demo "Generic Tour" and rewrite all
|
||||
> tooltips to speak directly to Acme Corp (logistics company, 400 trucks,
|
||||
> pain point is manual dispatch). Mention their industry where natural. Give
|
||||
> me the personalized share link format with viewer token acme-corp.
|
||||
> Duplicate the demo "Generic Tour", then rewrite all tooltips in the copy to
|
||||
> speak directly to Acme Corp (logistics company, 400 trucks, pain point is
|
||||
> manual dispatch). Mention their industry where natural. Rename the copy
|
||||
> "Acme Corp Tour" and tell me when it's ready to publish — I'll send it with
|
||||
> viewer token acme-corp.
|
||||
|
||||
**Branching demo (choose-your-own path):**
|
||||
> On demo "Full Product Tour": add branch hotspots on step 1 — one labeled
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue