ava-engine 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1096 @@
1
+ "use strict";
2
+ // Ava dashboard: overview, calls, share links, playbooks, settings.
3
+ // Talks to /api/admin/*. Everything shown is escaped; pages are re-rendered from data.
4
+ (() => {
5
+ const page = () => document.getElementById("page");
6
+ const $ = (sel, el = document) => el.querySelector(sel);
7
+ const $$ = (sel, el = document) => [...el.querySelectorAll(sel)];
8
+ const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
9
+
10
+ async function api(path, { method = "GET", body } = {}) {
11
+ const r = await fetch(`/api/admin/${path}`, { method, headers: body ? { "Content-Type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined });
12
+ const j = await r.json().catch(() => ({}));
13
+ if (!r.ok) throw Object.assign(new Error(j.error || (j.errors || []).join(" ") || `error ${r.status}`), { status: r.status, errors: j.errors });
14
+ return j;
15
+ }
16
+
17
+ const I = {
18
+ plus: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>',
19
+ copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg>',
20
+ open: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10"/></svg>',
21
+ edit: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/></svg>',
22
+ trash: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9.5 7V4.5h5V7M6.5 7l.8 12a1.5 1.5 0 0 0 1.5 1.4h6.4a1.5 1.5 0 0 0 1.5-1.4l.8-12"/></svg>',
23
+ pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5v14M15 5v14"/></svg>',
24
+ play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5v14l11-7Z"/></svg>',
25
+ check: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>',
26
+ mic: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3"/></svg>',
27
+ link: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>',
28
+ book: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5z"/><path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H20v3H6.5A2.5 2.5 0 0 1 4 20.5Z"/></svg>',
29
+ headset: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 14v-2a8 8 0 0 1 16 0v2"/><rect x="2.5" y="14" width="4.5" height="6.5" rx="1.6"/><rect x="17" y="14" width="4.5" height="6.5" rx="1.6"/></svg>',
30
+ info: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg>',
31
+ warn: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4 2.5 20h19L12 4Z"/><path d="M12 10v4M12 17h.01"/></svg>',
32
+ user: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20.5a8 8 0 0 1 16 0"/></svg>',
33
+ back: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18 9 12l6-6"/></svg>',
34
+ send: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12 20 4l-6 16-3-7-7-1Z"/></svg>',
35
+ };
36
+
37
+ // ── formatting ──
38
+ const money = (n) => (n == null ? "—" : n === 0 ? "$0.00" : n < 0.01 ? "<$0.01" : `$${n.toFixed(2)}`);
39
+ const dur = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const m = Math.floor(sec / 60), s = sec % 60; return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}:${String(s).padStart(2, "0")}`; };
40
+ const since = (iso) => (Date.now() - Date.parse(iso)) / 1000;
41
+ function ago(iso) {
42
+ if (!iso) return "never";
43
+ const s = since(iso);
44
+ if (s < 45) return "just now";
45
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
46
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
47
+ return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
48
+ }
49
+ const clock = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
50
+ const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
51
+ function stepsBar(w) {
52
+ if (!w) return '<span class="meta">—</span>';
53
+ const bars = Array.from({ length: Math.min(w.total, 10) }, (_, i) => `<i class="${i < w.done ? "on" : ""}"></i>`).join("");
54
+ return `<span class="steps-bar" aria-hidden="true">${bars}</span> <span class="mono" style="font-size:12.5px;color:var(--meta)">${w.done}/${w.total}</span><span class="sr">${w.done} of ${w.total} steps</span>`;
55
+ }
56
+
57
+ // ── chrome: toast, confirm, status ──
58
+ let toastTimer;
59
+ function toast(text, kind = "") {
60
+ const t = document.getElementById("toast");
61
+ t.textContent = text; t.className = "toast " + kind; t.hidden = false;
62
+ clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, 2600);
63
+ }
64
+ function confirmBox(title, text, ok = "Delete") {
65
+ const d = document.getElementById("confirm");
66
+ $("#confirmTitle").textContent = title; $("#confirmText").textContent = text; $("#confirmOk").textContent = ok;
67
+ d.showModal();
68
+ return new Promise((r) => d.addEventListener("close", () => r(d.returnValue === "ok"), { once: true }));
69
+ }
70
+ async function copy(text, what = "Link") {
71
+ try { await navigator.clipboard.writeText(text); toast(`${what} copied`); return; } catch {}
72
+ // No clipboard permission (or not a secure context): the old select-and-copy route.
73
+ const ta = Object.assign(document.createElement("textarea"), { value: text, readOnly: true });
74
+ ta.style.cssText = "position:fixed;opacity:0;pointer-events:none";
75
+ document.body.append(ta); ta.select();
76
+ let ok = false; try { ok = document.execCommand("copy"); } catch {}
77
+ ta.remove();
78
+ toast(ok ? `${what} copied` : "Couldn't copy. Select the link and copy it yourself.", ok ? "" : "err");
79
+ }
80
+ function setStatus(st) {
81
+ $("#ver").textContent = `v${st.version}`;
82
+ $("#statusDot").className = "status-dot" + (st.mock ? " demo" : "");
83
+ $("#statusTitle").textContent = st.mock ? "Demo mode" : st.spend.capReached ? "Daily cap reached" : "Ava is live";
84
+ $("#statusSub").textContent = `${money(st.spend.today)} today${st.spend.dailyCap ? ` of $${st.spend.dailyCap}` : ""}`;
85
+ }
86
+ const head = (eyebrow, title, sub, actions = "") => `
87
+ <header class="ph"><div><p class="eyebrow">${esc(eyebrow)}</p><h1>${esc(title)}</h1>${sub ? `<p class="sub">${sub}</p>` : ""}</div><div class="row">${actions}</div></header>`;
88
+ const empty = (icon, title, text, action = "") => `<div class="empty"><span class="glyph">${icon}</span><h3>${esc(title)}</h3><p>${text}</p>${action}</div>`;
89
+ const banner = (kind, html) => `<div class="banner ${kind}">${kind === "info" ? I.info : I.warn}<div>${html}</div></div>`;
90
+
91
+ // ── calls (shared by overview and the calls page) ──
92
+ const source = (c) => c.link ? `<div class="who">${I.link}<div><b>${esc(c.link.name)}</b></div></div>` : '<span class="meta">Test call</span>';
93
+ const product = (c) => c.platform ? esc(c.platform.name) : '<span class="meta">Anything</span>';
94
+ function callsTable(rows, { live = false, clickable = false } = {}) {
95
+ if (!rows.length) return "";
96
+ return `<div class="table-wrap"><table>
97
+ <thead><tr><th>${live ? "Started" : "When"}</th><th>From</th><th>Product</th><th>Progress</th><th>Person</th><th class="num">${live ? "Running" : "Length"}</th><th class="num">Turns</th><th class="num">Cost</th>${live ? "<th></th>" : ""}</tr></thead>
98
+ <tbody>${rows.map((c) => `<tr${clickable ? ` class="clickable" data-href="#calls/${esc(c.id)}" tabindex="0" aria-label="Open call from ${esc(ago(c.endedAt || c.startedAt))}"` : ""}>
99
+ <td>${live ? `<span class="badge live">live</span> ${esc(clock(c.startedAt))}` : `${esc(ago(c.endedAt || c.startedAt))}`}</td>
100
+ <td>${source(c)}</td>
101
+ <td>${product(c)}${c.currentStep ? `<div class="meta" style="font-size:12.5px">on “${esc(c.currentStep)}”</div>` : ""}</td>
102
+ <td>${c.workflow?.completed ? '<span class="badge ok">done</span>' : stepsBar(c.workflow)}</td>
103
+ <td>${c.handoff ? `<span class="badge ${c.handoff.state === "joined" ? "ok" : "warn"}">${c.handoff.state === "joined" ? "with specialist" : "waiting"}</span>` : c.handoffRequested ? '<span class="badge mute">asked</span>' : '<span class="meta">—</span>'}</td>
104
+ <td class="num">${dur(live ? since(c.startedAt) : c.durationSec)}</td>
105
+ <td class="num">${c.turns}</td>
106
+ <td class="num">${money(c.costUsd)}</td>
107
+ ${live ? `<td>${c.handoff?.joinPath ? `<a class="btn accent sm" href="${esc(c.handoff.joinPath)}" target="_blank" rel="noopener">${I.headset}Join</a>` : ""}</td>` : ""}
108
+ </tr>`).join("")}</tbody></table></div>`;
109
+ }
110
+
111
+ // ═══════════ Overview ═══════════
112
+ async function overview() {
113
+ const d = await api("overview");
114
+ setStatus(d.status);
115
+ const st = d.status, t = d.today;
116
+ const hour = new Date().getHours();
117
+ const hello = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening";
118
+ const done = d.checklist.filter((c) => c.done).length;
119
+ const cap = st.spend.dailyCap, pct = cap ? Math.min(100, (st.spend.today / cap) * 100) : 0;
120
+ return `
121
+ ${head("Overview", hello, "Here's what Ava is doing for your customers today.", `<a class="btn accent" href="#links/new">${I.plus}New share link</a>`)}
122
+ <section class="hero ${st.mock ? "demo" : ""}" aria-label="Agent status">
123
+ <div class="orb" aria-hidden="true"></div>
124
+ <div>
125
+ <p class="eyebrow">${st.mock ? "Demo mode" : st.spend.capReached ? "Paused for today" : "Ava is live"}</p>
126
+ <h2>${st.mock ? "Replies are canned for now" : st.spend.capReached ? "Today's spending cap is reached" : "Answering with real AI"}</h2>
127
+ <p>${st.mock ? "Voice, screen sharing and the step tracker all work, but Ava can't read screens or answer for real until you connect OpenRouter: <code>npx ava-engine setup</code>."
128
+ : st.spend.capReached ? `New calls are refused until midnight UTC. Raise <code>DAILY_BUDGET_USD</code> to allow more.`
129
+ : "Customers get real answers from your OpenRouter models. Share a link to put Ava in front of them."}</p>
130
+ </div>
131
+ <div class="row"><a class="btn light" href="/app" target="_blank" rel="noopener">${I.mic}Test a call</a></div>
132
+ </section>
133
+ <div class="grid g4" style="margin-top:16px">
134
+ <div class="card stat"><div class="label">Calls today</div><div class="value">${t.calls}</div><div class="foot">${t.completed ? `${plural(t.completed, "task")} finished` : `${plural(t.minutes, "minute")} on calls`}</div></div>
135
+ <div class="card stat"><div class="label">Live now</div><div class="value">${t.live}</div><div class="foot">${t.live ? `<a href="#calls">See live calls</a>` : "Updates every few seconds"}</div></div>
136
+ <div class="card stat"><div class="label">Waiting for a person</div><div class="value">${t.waiting}</div><div class="foot">${plural(t.handoffs, "request")} today</div></div>
137
+ <div class="card stat"><div class="label">Spend today</div><div class="value">${money(st.spend.today)}</div>${cap ? `<div class="meter ${pct > 85 ? "hot" : ""}"><i style="width:${pct.toFixed(1)}%"></i></div><div class="foot">of $${cap} daily cap</div>` : `<div class="foot">No daily cap · <code>DAILY_BUDGET_USD</code></div>`}</div>
138
+ </div>
139
+ <div class="grid g2" style="margin-top:16px">
140
+ <section class="card" aria-labelledby="h-live">
141
+ <div class="card-head"><h2 id="h-live">Live calls</h2><a class="meta" href="#calls">All calls</a></div>
142
+ ${d.live.length ? callsTable(d.live, { live: true }) : empty(I.headset, "No one's on a call", "When a customer opens one of your share links, their call shows up here. If they ask for a person, you can join from here.")}
143
+ </section>
144
+ <section class="card" aria-labelledby="h-start">
145
+ <div class="card-head"><h2 id="h-start">Get started</h2><span class="progress-ring">${done}/${d.checklist.length}</span></div>
146
+ <ul class="check">${d.checklist.map((c) => `
147
+ <li class="${c.done ? "done" : ""}"><span class="tick">${c.done ? I.check : ""}</span>
148
+ <span class="ctext"><b>${esc(c.label)}</b>${!c.done ? checklistHint(c) : ""}</span></li>`).join("")}</ul>
149
+ </section>
150
+ </div>
151
+ <section class="card" style="margin-top:16px" aria-labelledby="h-recent">
152
+ <div class="card-head"><h2 id="h-recent">Recent calls</h2><a class="meta" href="#calls/history">History</a></div>
153
+ ${d.recent.length ? callsTable(d.recent, { clickable: true }) : empty(I.headset, "No calls yet", "Start a test call to see how a session feels, then share a link with a customer.", `<a class="btn ghost sm" href="/app" target="_blank" rel="noopener">${I.mic}Test a call</a>`)}
154
+ </section>`;
155
+ }
156
+ function checklistHint(c) {
157
+ if (c.id === "playbook") return '<span><a href="#playbooks/new">Create a playbook</a></span>';
158
+ if (c.id === "link") return '<span><a href="#links/new">Create a share link</a></span>';
159
+ return c.hint ? `<span><code>${esc(c.hint)}</code></span>` : "";
160
+ }
161
+
162
+ // ═══════════ Calls ═══════════
163
+ // History is filtered here in the page (the server keeps the latest 500 calls).
164
+ const callFilter = { q: "", outcome: "all", product: "", link: "" };
165
+ const outcomeOf = (c) => c.workflow?.completed ? "done" : c.handoffRequested ? "person" : c.workflow ? "unfinished" : "other";
166
+ function filtered(rows) {
167
+ const q = callFilter.q.toLowerCase();
168
+ return rows.filter((c) => (callFilter.outcome === "all" || outcomeOf(c) === callFilter.outcome)
169
+ && (!callFilter.product || c.platform?.id === callFilter.product)
170
+ && (!callFilter.link || (callFilter.link === "test" ? !c.link : c.link?.id === callFilter.link))
171
+ && (!q || [c.platform?.name, c.link?.name, c.workflow?.title, c.id].some((v) => String(v || "").toLowerCase().includes(q))));
172
+ }
173
+ let callsData = null;
174
+ async function calls(sub) {
175
+ if (sub && /^[0-9a-f-]{36}$/.test(sub)) return callDetail(sub);
176
+ const d = callsData = await api("calls");
177
+ const tab = sub === "history" ? "history" : "live";
178
+ const products = [...new Map(d.recent.filter((c) => c.platform).map((c) => [c.platform.id, c.platform.name])).entries()];
179
+ const links = [...new Map(d.recent.filter((c) => c.link).map((c) => [c.link.id, c.link.name])).entries()];
180
+ const rows = filtered(d.recent);
181
+ const sel = (id, label, value, opts) => `<label class="sr" for="${id}">${label}</label><select id="${id}" data-filter="${id}">${opts.map(([v, t]) => `<option value="${esc(v)}" ${v === value ? "selected" : ""}>${esc(t)}</option>`).join("")}</select>`;
182
+ return `
183
+ ${head("Calls", "Calls", d.transcripts ? "Who's talking to Ava right now, and how past calls went. Transcripts are on: open a call to read it." : "Who's talking to Ava right now, and how past calls went. Ava keeps numbers and steps, not what was said, unless you turn transcripts on in Settings.")}
184
+ <div class="tabs" role="tablist" aria-label="Calls">
185
+ <button role="tab" aria-selected="${tab === "live"}" data-href="#calls">Live · ${d.live.length}</button>
186
+ <button role="tab" aria-selected="${tab === "history"}" data-href="#calls/history">History · ${d.recent.length}</button>
187
+ </div>
188
+ ${tab === "live" ? `<section class="card">${d.live.length ? callsTable(d.live, { live: true }) : empty(I.headset, "No live calls", "Calls from your share links and test calls appear here while they're running.")}</section>` : `
189
+ <div class="filters">
190
+ <label class="sr" for="fq">Search calls</label><input id="fq" type="search" placeholder="Search product, link or call ID" value="${esc(callFilter.q)}" data-filter="fq">
191
+ ${sel("fo", "Outcome", callFilter.outcome, [["all", "All outcomes"], ["done", "Finished the journey"], ["person", "Asked for a person"], ["unfinished", "Didn't finish"], ["other", "Help with anything"]])}
192
+ ${sel("fp", "Product", callFilter.product, [["", "All products"], ...products])}
193
+ ${sel("fl", "Link", callFilter.link, [["", "All links"], ["test", "Test calls"], ...links])}
194
+ <button class="btn ghost sm" data-action="export-calls" ${rows.length ? "" : "disabled"}>Export CSV</button>
195
+ </div>
196
+ <section class="card">${rows.length ? callsTable(rows, { clickable: true }) : d.recent.length ? empty(I.headset, "No calls match", "Try another search or filter.") : empty(I.headset, "No finished calls yet", "Once a call ends, its length, progress and cost are kept here.")}</section>
197
+ <p class="hint" style="margin-top:10px">${plural(rows.length, "call")}${rows.length !== d.recent.length ? ` of ${d.recent.length}` : ""}.</p>`}`;
198
+ }
199
+ function exportCalls() {
200
+ const rows = filtered(callsData.recent);
201
+ const cols = [["started", (c) => c.startedAt], ["ended", (c) => c.endedAt], ["link", (c) => c.link?.name || "test call"], ["product", (c) => c.platform?.name || ""], ["mode", (c) => c.mode],
202
+ ["outcome", outcomeOf], ["steps_done", (c) => c.workflow?.done ?? ""], ["steps_total", (c) => c.workflow?.total ?? ""], ["asked_for_person", (c) => c.handoffRequested ? "yes" : "no"],
203
+ ["duration_sec", (c) => c.durationSec], ["turns", (c) => c.turns], ["cost_usd", (c) => c.costUsd], ["id", (c) => c.id]];
204
+ const cell = (v) => { const t = String(v ?? ""); return /[",\n]/.test(t) || /^[=+\-@]/.test(t) ? `"${t.replace(/^([=+\-@])/, "'$1").replace(/"/g, '""')}"` : t; }; // no spreadsheet formulas
205
+ const csv = [cols.map((c) => c[0]).join(","), ...rows.map((r) => cols.map(([, f]) => cell(f(r))).join(","))].join("\n");
206
+ const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(new Blob([csv], { type: "text/csv" })), download: `ava-calls-${new Date().toISOString().slice(0, 10)}.csv` });
207
+ document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
208
+ }
209
+
210
+ // ── one call ──
211
+ const OUTCOME = { done: ["ok", "Finished the journey"], person: ["warn", "Asked for a person"], unfinished: ["mute", "Didn't finish"], other: ["mute", "Help with anything"] };
212
+ const WHO = { user: "Customer", ava: "Ava", specialist: "Specialist", system: "" };
213
+ async function callDetail(id) {
214
+ let d;
215
+ try { d = await api(`calls/${id}`); } catch (e) { return `<a class="btn ghost sm" href="#calls/history" style="margin-bottom:18px">${I.back}Calls</a>${banner("err", esc(e.message))}`; }
216
+ const c = d.call, t = d.transcript, [kind, label] = OUTCOME[outcomeOf(c)];
217
+ const when = new Date(c.startedAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
218
+ return `
219
+ <a class="btn ghost sm" href="#calls/history" style="margin-bottom:18px">${I.back}Calls</a>
220
+ ${head("Call", c.platform ? c.platform.name : "Help with anything", `${esc(when)} · ${c.link ? `from “${esc(c.link.name)}”` : "a test call"}`, `<button class="btn ghost sm" data-action="delete-call" data-id="${esc(c.id)}">${I.trash}Delete call</button>`)}
221
+ <div class="grid g4">
222
+ <div class="card stat"><div class="label">Outcome</div><div style="margin-top:12px"><span class="badge ${kind}">${label}</span></div></div>
223
+ <div class="card stat"><div class="label">Length</div><div class="value">${dur(c.durationSec)}</div></div>
224
+ <div class="card stat"><div class="label">Turns</div><div class="value">${c.turns}</div></div>
225
+ <div class="card stat"><div class="label">Cost</div><div class="value">${money(c.costUsd)}</div></div>
226
+ </div>
227
+ <div class="grid g2" style="margin-top:16px;align-items:start">
228
+ <section class="card" aria-labelledby="h-conv"><div class="card-head"><h2 id="h-conv">Conversation</h2>${t ? `<span class="meta">${plural(t.lines.length, "line")}</span>` : ""}</div>
229
+ ${t ? (t.lines.length ? `<ol class="convo">${t.lines.map((l) => `<li class="${esc(l.role)}">${WHO[l.role] ? `<span class="who-l">${WHO[l.role]}<span class="mono">${esc(clock(l.at))}</span></span>` : ""}<p>${esc(l.text)}</p></li>`).join("")}</ol>` : empty(I.headset, "Nothing was said", ""))
230
+ : empty(I.info, "No transcript", c.transcript === false && d.transcripts ? "This call ended before transcripts were turned on, or its transcript was deleted." : "Transcripts are off, so Ava kept only numbers and steps. Turn them on in <a href=\"#settings\">Settings → Data and privacy</a>.")}
231
+ </section>
232
+ <div class="stack">
233
+ ${t?.recap ? `<section class="card card-pad"><p class="eyebrow">Recap</p><h2>${esc(t.recap.headline)}</h2><p style="color:var(--ink2);margin:8px 0 0">${esc(t.recap.summary)}</p>${t.recap.highlights?.length ? `<ul class="hl">${t.recap.highlights.map((h) => `<li>${esc(h)}</li>`).join("")}</ul>` : ""}</section>` : ""}
234
+ <section class="card" aria-labelledby="h-steps"><div class="card-head"><h2 id="h-steps">Journey</h2>${c.workflow ? `<span class="meta">${c.workflow.done}/${c.workflow.total}</span>` : ""}</div>
235
+ ${c.steps ? `<ol class="jsteps">${c.steps.map((s) => `<li class="${s.done ? "done" : ""}"><span class="tick">${s.done ? I.check : ""}</span>${esc(s.title)}</li>`).join("")}</ol>` : `<p class="hint" style="padding:16px 22px">No step tracker on this call.</p>`}
236
+ </section>
237
+ <section class="card"><dl class="dl">
238
+ <dt>Mode</dt><dd>${c.mode === "onboarding" ? "Onboarding" : "Help with anything"}</dd>
239
+ <dt>Ended</dt><dd>${c.reason === "hangup" ? "Customer left" : "Timed out"}</dd>
240
+ <dt>Asked for a person</dt><dd>${c.handoffRequested ? "Yes" : "No"}</dd>
241
+ <dt>Call ID</dt><dd class="mono" style="font-size:12.5px">${esc(c.id)}</dd></dl></section>
242
+ </div>
243
+ </div>`;
244
+ }
245
+
246
+ // ═══════════ Share links ═══════════
247
+ let linksCache = null, productsCache = null;
248
+ const linkUrl = (l) => `${(linksCache?.publicUrl || location.origin)}${l.path}`;
249
+ async function links(sub) {
250
+ const [d, pb] = await Promise.all([api("links"), api("playbooks")]);
251
+ linksCache = d; productsCache = pb.playbooks.filter((p) => p.shown || p.source === "yours");
252
+ const L = d.links;
253
+ let notes = "";
254
+ if (d.localOnly) notes += banner("warn", "<b>Links only open on this computer right now</b>, because Ava is set up for “Just me”. To share them, run <code>npx ava-engine setup</code>, choose “My team or customers”, and put Ava on a public address.");
255
+ else if (!d.publicUrl) notes += banner("info", `Links use this page's address (<code>${esc(location.origin)}</code>). If customers reach Ava somewhere else, set <code>PUBLIC_URL</code>.`);
256
+ if (!d.password && !d.localOnly) notes += banner("warn", "<b>No password is set</b>, so anyone who can reach this server can also use the test page and the dashboard API. Run <code>npx ava-engine setup</code> and choose a password.");
257
+ setTimeout(() => { if (sub === "new") openLinkDrawer(); else if (sub && sub.startsWith("edit/")) openLinkDrawer(L.find((l) => l.id === sub.slice(5))); }, 0);
258
+ return `
259
+ ${head("Share links", "Put Ava in front of customers", "Each link opens a call already set up for one product and case. Customers don't pick anything or see the console: they just press start.", `<button class="btn accent" data-action="new-link">${I.plus}New share link</button>`)}
260
+ ${notes}
261
+ ${L.length ? `<div class="grid gcards">${L.map(linkCard).join("")}</div>`
262
+ : `<section class="card">${empty(I.link, "No share links yet", "Make one for each place you'd put Ava: a trial welcome email, the help menu in your app, a sales demo.", `<button class="btn accent sm" data-action="new-link">${I.plus}New share link</button>`)}</section>`}`;
263
+ }
264
+ function linkState(l) {
265
+ if (!l.enabled) return '<span class="badge mute">paused</span>';
266
+ if (l.expiresAt && Date.parse(l.expiresAt) <= Date.now()) return '<span class="badge err">expired</span>';
267
+ if (l.productMissing) return '<span class="badge err">product missing</span>';
268
+ if (l.dailyLimit && l.usage.today >= l.dailyLimit) return '<span class="badge warn">limit reached</span>';
269
+ return '<span class="badge ok">active</span>';
270
+ }
271
+ function linkCard(l) {
272
+ const url = linkUrl(l);
273
+ return `<article class="card lcard ${l.enabled ? "" : "off"}">
274
+ <div class="lcard-top">
275
+ <span class="dot" style="margin-top:7px;background:${esc(l.productAccent || "#4F46E5")}"></span>
276
+ <div class="lcard-title"><b>${esc(l.name)}</b><span class="meta" style="font-size:13px">${l.mode === "onboarding" ? `Onboarding · ${esc(l.productName || l.product)}` : `Help with anything${l.productName ? ` · knows ${esc(l.productName)}` : ""}`}</span></div>
277
+ ${linkState(l)}
278
+ </div>
279
+ <div class="lcard-meta">
280
+ ${l.handoff ? '<span class="chip">Talk to a person</span>' : ""}${l.steps ? '<span class="chip">Step tracker</span>' : ""}${l.recap ? '<span class="chip">Recap</span>' : ""}
281
+ ${l.dailyLimit ? `<span class="chip">${l.usage.today}/${l.dailyLimit} today</span>` : ""}${l.expiresAt ? `<span class="chip">until ${esc(new Date(l.expiresAt).toLocaleDateString())}</span>` : ""}
282
+ </div>
283
+ <div class="urlbox"><span title="${esc(url)}">${esc(url)}</span><button class="icon-btn" data-action="copy" data-url="${esc(url)}" aria-label="Copy link for ${esc(l.name)}">${I.copy}</button><a class="icon-btn" href="${esc(url)}" target="_blank" rel="noopener" aria-label="Open ${esc(l.name)}">${I.open}</a></div>
284
+ <div class="lcard-foot"><span class="grow">${plural(l.usage.total, "call")} · used ${esc(ago(l.usage.lastUsedAt))}</span>
285
+ <button class="icon-btn" data-action="toggle-link" data-id="${esc(l.id)}" aria-label="${l.enabled ? "Pause" : "Resume"} ${esc(l.name)}" title="${l.enabled ? "Pause" : "Resume"}">${l.enabled ? I.pause : I.play}</button>
286
+ <button class="icon-btn" data-action="embed-link" data-id="${esc(l.id)}" aria-label="Embed ${esc(l.name)} on your site" title="Embed on your site"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8 8-4 4 4 4M16 8l4 4-4 4M13.5 5l-3 14"/></svg></button>
287
+ <a class="icon-btn" href="#links/edit/${esc(l.id)}" aria-label="Edit ${esc(l.name)}" title="Edit">${I.edit}</a>
288
+ <button class="icon-btn danger" data-action="delete-link" data-id="${esc(l.id)}" aria-label="Delete ${esc(l.name)}" title="Delete">${I.trash}</button>
289
+ </div>
290
+ </article>`;
291
+ }
292
+ function embedDrawer(l) {
293
+ const base = linksCache?.publicUrl || location.origin;
294
+ const snippet = (o) => `<script src="${base}/embed.js" data-link="${l.id}"${o.label && o.label !== "Talk to Ava" ? ` data-label="${o.label.replace(/"/g, "&quot;")}"` : ""}${o.color ? ` data-color="${o.color}"` : ""}${o.left ? ' data-position="left"' : ""}${o.tab ? ' data-open="tab"' : ""} async></script>`;
295
+ openDrawer("Embed on your site", `
296
+ <p class="hint">Paste this before <code>&lt;/body&gt;</code> on your site or in your product. Customers get a <b>${esc(l.name)}</b> button that opens Ava in a window next to your page, so they can share it and keep working.</p>
297
+ <div class="field"><label for="emLabel">Button text</label><input id="emLabel" type="text" maxlength="40" value="Talk to Ava"></div>
298
+ <div class="fgrid">
299
+ <div class="field"><label for="emPos">Corner</label><select id="emPos"><option value="right">Bottom right</option><option value="left">Bottom left</option></select></div>
300
+ <div class="field"><label for="emOpen">Opens in</label><select id="emOpen"><option value="window">A window beside the page</option><option value="tab">A new tab</option></select></div>
301
+ </div>
302
+ <div class="field"><span class="label">Preview</span><div class="embed-preview"><span class="embed-btn" id="emPreview" style="${l.productAccent ? `--c:${esc(l.productAccent)}` : ""}"><i></i><span>Talk to Ava</span></span></div></div>
303
+ <div class="field"><label for="emCode">Code</label><textarea id="emCode" class="mono-in" rows="4" readonly></textarea></div>
304
+ <p class="hint">Your site's Content-Security-Policy must allow scripts from <code>${esc(base)}</code>. Open it from your own menu with <code>Ava.open()</code>.</p>
305
+ <div class="drawer-foot"><button type="button" class="btn ghost" data-action="close-drawer">Close</button><button type="button" class="btn accent" id="emCopy">${I.copy}Copy code</button></div>`, closeDrawer);
306
+ $("#drawer .eyebrow").textContent = "Share link";
307
+ const sync = () => {
308
+ const o = { label: $("#emLabel").value.trim() || "Talk to Ava", left: $("#emPos").value === "left", tab: $("#emOpen").value === "tab", color: l.productAccent || "" };
309
+ $("#emCode").value = snippet(o); $("#emPreview span").textContent = o.label;
310
+ };
311
+ ["#emLabel", "#emPos", "#emOpen"].forEach((s) => $(s).addEventListener("input", sync));
312
+ $("#emCopy").addEventListener("click", () => copy($("#emCode").value, "Embed code"));
313
+ sync();
314
+ }
315
+ function linkBody(l) {
316
+ return { name: l.name, mode: l.mode, product: l.product, title: l.title, intro: l.intro, handoff: l.handoff, steps: l.steps, recap: l.recap, expiresAt: l.expiresAt, dailyLimit: l.dailyLimit, enabled: l.enabled };
317
+ }
318
+
319
+ let lastFocus = null;
320
+ function openLinkDrawer(link = null) {
321
+ const l = link || { name: "", mode: "onboarding", product: productsCache.find((p) => p.source === "yours")?.id || productsCache.find((p) => !p.demoOnly)?.id || null, handoff: true, steps: true, recap: true, enabled: true };
322
+ const products = productsCache.filter((p) => !p.demoOnly);
323
+ lastFocus = document.activeElement;
324
+ $("#drawerTitle").textContent = link ? "Edit share link" : "New share link";
325
+ $("#drawer .eyebrow").textContent = "Share link";
326
+ $("#linkForm").innerHTML = `
327
+ <div class="field"><label for="lfName">Name <span class="opt">(only you see this)</span></label><input id="lfName" type="text" maxlength="80" required value="${esc(l.name)}" placeholder="e.g. Trial welcome email"></div>
328
+ <div class="field"><span class="label" id="lfModeL">What should Ava do?</span>
329
+ <div class="choice" role="radiogroup" aria-labelledby="lfModeL">
330
+ <label><input type="radio" name="lfMode" value="onboarding" ${l.mode !== "flexible" ? "checked" : ""}><span><b>Onboard onto a product</b><span>Walk them through its steps to their first win</span></span></label>
331
+ <label><input type="radio" name="lfMode" value="flexible" ${l.mode === "flexible" ? "checked" : ""}><span><b>Help with anything</b><span>Answer whatever they're doing on screen</span></span></label>
332
+ </div></div>
333
+ <div class="field"><label for="lfProduct">Product</label>
334
+ <select id="lfProduct">${l.mode === "flexible" ? '<option value="">None (general help)</option>' : ""}${products.map((p) => `<option value="${esc(p.id)}" ${p.id === l.product ? "selected" : ""}>${esc(p.name)}${p.source === "yours" ? "" : " (sample)"}</option>`).join("")}</select>
335
+ <p class="hint">From your <a href="#playbooks">playbooks</a>. ${products.some((p) => p.source === "yours") ? "" : '<a href="#playbooks/new">Add your own product</a> first.'}</p></div>
336
+ <div class="field"><label for="lfTitle">Heading customers see <span class="opt">(optional)</span></label><input id="lfTitle" type="text" maxlength="80" value="${esc(l.title || "")}" placeholder="Get set up on your product"></div>
337
+ <div class="field"><label for="lfIntro">Intro <span class="opt">(optional)</span></label><textarea id="lfIntro" maxlength="240" placeholder="One or two sentences under the heading.">${esc(l.intro || "")}</textarea></div>
338
+ <div class="field"><span class="label">On the call</span><div>
339
+ <label class="toggle"><input type="checkbox" id="lfHandoff" ${l.handoff ? "checked" : ""}><span><b>“Talk to a person” button</b><span>Your team gets a join link (in Slack, if set up)</span></span></label>
340
+ <label class="toggle"><input type="checkbox" id="lfSteps" ${l.steps ? "checked" : ""}><span><b>Step tracker</b><span>Show the journey and tick steps off as they're done</span></span></label>
341
+ <label class="toggle"><input type="checkbox" id="lfRecap" ${l.recap ? "checked" : ""}><span><b>Recap at the end</b><span>A short summary of what they got done</span></span></label>
342
+ <label class="toggle"><input type="checkbox" id="lfEnabled" ${l.enabled ? "checked" : ""}><span><b>Link is active</b><span>Turn off to pause it without deleting</span></span></label>
343
+ </div></div>
344
+ <div class="fgrid">
345
+ <div class="field"><label for="lfExpires">Expires <span class="opt">(optional)</span></label><input id="lfExpires" type="date" value="${l.expiresAt ? esc(l.expiresAt.slice(0, 10)) : ""}"></div>
346
+ <div class="field"><label for="lfLimit">Calls per day <span class="opt">(optional)</span></label><input id="lfLimit" type="number" min="1" step="1" inputmode="numeric" value="${esc(l.dailyLimit ?? "")}" placeholder="No limit"></div>
347
+ </div>
348
+ <ul class="errors" id="lfErrors" role="alert"></ul>
349
+ <div class="drawer-foot"><button type="button" class="btn ghost" data-action="close-drawer">Cancel</button><button type="submit" class="btn accent">${link ? "Save changes" : `${I.link}Create link`}</button></div>`;
350
+ $$('input[name="lfMode"]').forEach((r) => r.addEventListener("change", () => {
351
+ const sel = $("#lfProduct"), flex = $('input[name="lfMode"]:checked').value === "flexible";
352
+ const none = sel.querySelector('option[value=""]');
353
+ if (flex && !none) sel.insertAdjacentHTML("afterbegin", '<option value="">None (general help)</option>');
354
+ if (!flex && none) none.remove();
355
+ }));
356
+ $("#linkForm").onsubmit = async (e) => {
357
+ e.preventDefault();
358
+ const exp = $("#lfExpires").value;
359
+ const body = {
360
+ name: $("#lfName").value, mode: $('input[name="lfMode"]:checked').value, product: $("#lfProduct").value || null,
361
+ title: $("#lfTitle").value, intro: $("#lfIntro").value,
362
+ handoff: $("#lfHandoff").checked, steps: $("#lfSteps").checked, recap: $("#lfRecap").checked, enabled: $("#lfEnabled").checked,
363
+ expiresAt: exp ? new Date(`${exp}T23:59:59`).toISOString() : null, dailyLimit: $("#lfLimit").value || null,
364
+ };
365
+ try {
366
+ const r = link ? await api(`links/${link.id}`, { method: "PUT", body }) : await api("links", { method: "POST", body });
367
+ closeDrawer();
368
+ go("#links");
369
+ if (!link) copy(linkUrl(r.link), "Link created and");
370
+ else toast("Link saved");
371
+ } catch (err) { $("#lfErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join(""); }
372
+ };
373
+ $("#scrim").hidden = false; $("#drawer").hidden = false;
374
+ $("#lfName").focus();
375
+ }
376
+ function closeDrawer() {
377
+ if ($("#drawer").hidden) return;
378
+ $("#scrim").hidden = true; $("#drawer").hidden = true;
379
+ if (/^#links\/(new|edit)/.test(location.hash)) history.replaceState(null, "", "#links");
380
+ lastFocus?.focus?.();
381
+ }
382
+
383
+ // ═══════════ Playbooks ═══════════
384
+ let libCache = null;
385
+ async function playbooks() {
386
+ const [d, lib] = await Promise.all([api("playbooks"), api("library").catch((e) => ({ error: e.message }))]);
387
+ libCache = lib;
388
+ const mine = d.playbooks.filter((p) => p.source === "yours"), built = d.playbooks.filter((p) => p.source === "built-in" && !p.demoOnly);
389
+ const problems = d.problems.filter((p) => p.errors.length);
390
+ return `
391
+ ${head("Playbooks", "What Ava knows about your products", "A playbook is Ava's script for one product: the steps to a customer's first win, how to do each one, and facts to answer questions with.", `<a class="btn accent" href="#playbooks/new">${I.plus}New playbook</a>`)}
392
+ ${problems.map((p) => banner("err", `<b>${esc(p.file)} isn't loaded:</b> ${p.errors.map(esc).join(" ")}`)).join("")}
393
+ <div class="section-title"><h3>Yours</h3><span class="mono">${esc(d.dir)}</span></div>
394
+ ${mine.length ? `<div class="grid gcards" style="margin-bottom:28px">${mine.map((p) => pbCard(p)).join("")}</div>`
395
+ : `<section class="card" style="margin-bottom:28px">${empty(I.book, "Describe your product", "Write the steps to your customers' first win and the facts they ask about. Ava follows it on every call. You can also start from a sample below.", `<a class="btn accent sm" href="#playbooks/new">${I.plus}New playbook</a>`)}</section>`}
396
+ <div class="section-title"><h3>Library</h3><span>${lib.error ? "" : `Shared playbooks you can install and adapt · <a href="https://github.com/Mr-vero/ava-playbooks" target="_blank" rel="noopener">contribute one</a>`}</span></div>
397
+ ${lib.error ? banner("warn", esc(lib.error)) : `<div class="grid gcards" style="margin-bottom:28px">${lib.playbooks.map(libCard).join("") || `<section class="card">${empty(I.book, "The library is empty", "")}</section>`}</div>`}
398
+ <div class="section-title"><h3>Samples</h3><span>${d.hideBuiltins ? "Hidden from the test page (<code>BUILTIN_PLAYBOOKS=off</code>)" : "Duplicate one to make it yours"}</span></div>
399
+ <div class="grid gcards">${built.map((p) => pbCard(p)).join("")}</div>`;
400
+ }
401
+ function libCard(p) {
402
+ const action = { available: `<button class="btn accent sm" data-action="lib-install" data-id="${esc(p.id)}">Install</button>`,
403
+ installed: '<span class="badge ok">installed</span>',
404
+ update: `<button class="btn accent sm" data-action="lib-install" data-id="${esc(p.id)}">Update</button>`,
405
+ taken: `<button class="btn ghost sm" data-action="lib-install" data-id="${esc(p.id)}" data-overwrite="1" title="You have your own playbook with this id">Replace yours</button>` }[p.state];
406
+ return `<article class="card pcard" style="--pc:${esc(p.accent || "#4F46E5")}">
407
+ <div class="pcard-body"><span class="cat">${esc(p.category || "Product")}</span><b>${esc(p.name)}</b>${p.tagline ? `<p>${esc(p.tagline)}</p>` : ""}
408
+ <div class="journey">${p.journey.map((s) => `<span>${esc(s)}</span>`).join("")}</div></div>
409
+ <div class="pcard-foot"><span class="grow">${plural(p.journey.length, "step")} · ${plural(p.facts, "fact")}${p.author ? ` · by ${esc(p.author)}` : ""}</span>
410
+ <button class="btn ghost sm" data-action="lib-preview" data-id="${esc(p.id)}">Preview</button>${can("editor") ? action : ""}</div></article>`;
411
+ }
412
+ function libPreview(id) {
413
+ const p = libCache.playbooks.find((x) => x.id === id);
414
+ openDrawer(p.name, `
415
+ <p class="hint">${esc(p.tagline || "")}</p>
416
+ <div class="card preview-card" style="--pc:${esc(p.accent || "#4F46E5")}"><div class="pv-top"><span>What customers see</span><b>Get set up on ${esc(p.name)}</b></div>
417
+ <ol class="pv-steps">${p.journey.map((s) => `<li>${esc(s)}</li>`).join("")}</ol></div>
418
+ <dl class="card dl"><dt>Facts</dt><dd>${p.facts}</dd>${p.author ? `<dt>By</dt><dd>${esc(p.author)}</dd>` : ""}<dt>File</dt><dd><a href="https://github.com/Mr-vero/ava-playbooks/blob/main/${esc(p.file)}" target="_blank" rel="noopener" class="mono" style="font-size:12.5px">${esc(p.file)}</a></dd></dl>
419
+ <p class="hint">Installing copies it into your playbooks: adapt it to your product and it's yours. Check every step and fact against the product before customers use it.</p>
420
+ <div class="drawer-foot"><button type="button" class="btn ghost" data-action="close-drawer">Close</button>${can("editor") && p.state !== "installed" ? `<button type="button" class="btn accent" data-action="lib-install" data-id="${esc(p.id)}" ${p.state === "taken" ? 'data-overwrite="1"' : ""}>${p.state === "update" ? "Update" : p.state === "taken" ? "Replace yours" : "Install"}</button>` : ""}</div>`, closeDrawer);
421
+ $("#drawer .eyebrow").textContent = "Library";
422
+ }
423
+ function pbCard(p) {
424
+ const yours = p.source === "yours";
425
+ return `<article class="card pcard" style="--pc:${esc(p.accent || "#4F46E5")}">
426
+ <div class="pcard-body"><span class="cat">${esc(p.category || "Product")}${p.overridden ? " · replaced by yours" : ""}</span><b>${esc(p.name)}</b>${p.tagline ? `<p>${esc(p.tagline)}</p>` : ""}
427
+ <div class="journey">${(p.journey || []).map((s) => `<span>${esc(s)}</span>`).join("")}</div></div>
428
+ <div class="pcard-foot"><span class="grow">${plural(p.journey.length, "step")} · ${plural((p.knowledge || []).length, "fact")}</span>
429
+ <a class="icon-btn" href="/app?mode=onboarding&amp;product=${encodeURIComponent(p.id)}" target="_blank" rel="noopener" aria-label="Test a call on ${esc(p.name)}" title="Test a call">${I.mic}</a>
430
+ ${yours ? `<a class="icon-btn" href="#playbooks/edit/${encodeURIComponent(p.id)}" aria-label="Edit ${esc(p.name)}" title="Edit">${I.edit}</a>
431
+ <button class="icon-btn danger" data-action="delete-pb" data-id="${esc(p.id)}" data-name="${esc(p.name)}" aria-label="Delete ${esc(p.name)}" title="Delete">${I.trash}</button>`
432
+ : `<a class="btn ghost sm" href="#playbooks/copy/${encodeURIComponent(p.id)}">${I.copy}Duplicate</a>`}
433
+ </div></article>`;
434
+ }
435
+
436
+ // ── playbook editor ──
437
+ const slug = (s) => String(s || "").toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-").slice(0, 40).replace(/^-|-$/g, "");
438
+ let ed = null, pendingDraft = null;
439
+ function draftDrawer() {
440
+ openDrawer("Draft with AI", `
441
+ <p class="hint">Point Ava at your getting-started docs and it drafts the journey, how to do each step and the facts customers ask about. You review everything before it's saved.</p>
442
+ <div class="field"><label for="dfName">Product name</label><input id="dfName" type="text" maxlength="60" value="${esc($("#pbName")?.value || "")}" required></div>
443
+ <div class="field"><label for="dfUrl">Docs page <span class="opt">(a public address)</span></label><input id="dfUrl" type="url" class="mono-in" placeholder="https://docs.yourproduct.com/getting-started"></div>
444
+ <div class="field"><label for="dfText">Or paste your guide <span class="opt">(either or both)</span></label><textarea id="dfText" rows="8" maxlength="60000" placeholder="Paste the steps a new customer follows, help-center articles, FAQs…"></textarea></div>
445
+ <ul class="errors" id="dfErrors" role="alert"></ul>
446
+ <p class="hint" id="dfStatus" role="status" aria-live="polite"></p>
447
+ <div class="drawer-foot"><button type="button" class="btn ghost" data-action="close-drawer">Cancel</button><button type="submit" class="btn accent" id="dfGo">${I.book}Draft playbook</button></div>`, async () => {
448
+ $("#dfGo").disabled = true; $("#dfErrors").innerHTML = ""; $("#dfStatus").textContent = "Reading your docs and drafting… this takes about 20 seconds.";
449
+ try {
450
+ const r = await api("playbooks/draft", { method: "POST", body: { name: $("#dfName").value, url: $("#dfUrl").value || undefined, text: $("#dfText").value || undefined } });
451
+ pendingDraft = r.playbook; closeDrawer();
452
+ toast(r.problems.length ? "Draft ready, with a few things to fix" : "Draft ready: review it, then save");
453
+ render(true);
454
+ } catch (err) { $("#dfErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join(""); $("#dfStatus").textContent = ""; }
455
+ finally { const b = $("#dfGo"); if (b) b.disabled = false; }
456
+ });
457
+ }
458
+ async function editor(sub) {
459
+ const d = await api("playbooks");
460
+ const [kind, id] = [sub.split("/")[0], decodeURIComponent(sub.split("/").slice(1).join("/"))];
461
+ const src = kind === "new" ? null : d.playbooks.find((p) => p.id === id);
462
+ if (kind !== "new" && !src) return `${head("Playbook", "Not found", "")}${banner("err", "That playbook doesn't exist.")}`;
463
+ let pb;
464
+ if (kind === "new") pb = d.template;
465
+ else pb = { ...src, knowledge: (src.knowledge || []).map((k) => ({ keywords: k.k, text: k.text })) };
466
+ if (kind === "copy") pb = { ...pb, id: `${src.id}-copy`, name: `${src.name} (copy)` };
467
+ if (kind === "new") pb = pendingDraft || { id: "", name: "", accent: "#4F46E5", journey: ["", "", ""], path: [], knowledge: [{ keywords: "", text: "" }] };
468
+ const fromDraft = kind === "new" && !!pendingDraft; pendingDraft = null;
469
+ ed = { previousId: kind === "edit" ? src.id : undefined, idTouched: kind === "edit", existing: kind === "edit", touched: kind !== "new" || fromDraft };
470
+ const n = Math.max(pb.journey?.length || 0, pb.path?.length || 0, 1);
471
+ const steps = Array.from({ length: n }, (_, i) => ({ title: pb.journey?.[i] || "", how: pb.path?.[i] || "" }));
472
+ const title = kind === "edit" ? pb.name : kind === "copy" ? "Duplicate playbook" : "New playbook";
473
+ return `
474
+ <a class="btn ghost sm" href="#playbooks" style="margin-bottom:18px">${I.back}Playbooks</a>
475
+ ${head("Playbook", title, "Ava follows this on every call for this product. Changes apply to new calls as soon as you save.", kind === "new" ? `<button class="btn ghost" data-action="draft-ai">${I.book}Draft with AI</button>` : "")}
476
+ ${fromDraft ? banner("info", "<b>This is an AI draft.</b> Check every step and fact against your product before saving: Ava will say these things to customers.") : ""}
477
+ <div class="editor">
478
+ <form class="stack" id="pbForm" novalidate>
479
+ <section class="card card-pad stack">
480
+ <h2>Basics</h2>
481
+ <div class="fgrid">
482
+ <div class="field"><label for="pbName">Product name</label><input id="pbName" type="text" maxlength="60" value="${esc(pb.name)}" placeholder="Acme" required></div>
483
+ <div class="field"><label for="pbId">ID <span class="opt">(in links and events)</span></label><input id="pbId" class="mono-in" type="text" maxlength="40" value="${esc(pb.id)}" placeholder="acme" pattern="[a-z0-9][a-z0-9-]*"></div>
484
+ <div class="field"><label for="pbCat">Category <span class="opt">(optional)</span></label><input id="pbCat" type="text" maxlength="300" value="${esc(pb.category || "")}" placeholder="Analytics"></div>
485
+ <div class="field"><label for="pbUrl">Product URL <span class="opt">(optional)</span></label><input id="pbUrl" type="url" maxlength="300" value="${esc(pb.url || "")}" placeholder="https://app.acme.com"></div>
486
+ <div class="field"><label for="pbAccentHex">Brand colour</label><div class="color-in"><input type="color" id="pbAccent" value="${esc(/^#[0-9a-f]{6}$/i.test(pb.accent || "") ? pb.accent : "#4F46E5")}" aria-label="Pick brand colour"><input id="pbAccentHex" class="mono-in" type="text" maxlength="7" value="${esc(pb.accent || "#4F46E5")}"></div><p class="hint">The call takes on this colour.</p></div>
487
+ <div class="field"><label for="pbEta">Minutes to finish <span class="opt">(rough)</span></label><input id="pbEta" type="number" min="1" max="120" value="${esc(pb.etaMin ?? "")}"></div>
488
+ <div class="field wide"><label for="pbTagline">One-line description <span class="opt">(optional)</span></label><input id="pbTagline" type="text" maxlength="300" value="${esc(pb.tagline || "")}" placeholder="What it does, and why setup takes a hand"></div>
489
+ <div class="field wide"><label for="pbWin">First win</label><input id="pbWin" type="text" maxlength="300" value="${esc(pb.firstWin || "")}" placeholder="their first report open on the dashboard"><p class="hint">What success looks like on screen. Ava stops guiding once it sees this.</p></div>
490
+ <div class="field wide"><label for="pbOptional">What can wait <span class="opt">(optional)</span></label><input id="pbOptional" type="text" maxlength="300" value="${esc(pb.optional || "")}" placeholder="Inviting teammates and billing can wait until after the first win."></div>
491
+ </div>
492
+ </section>
493
+ <section class="card card-pad stack">
494
+ <div class="section-title"><h2>Journey</h2><span>Up to 10 steps, each visible on screen when done</span></div>
495
+ <div class="rows" id="pbSteps">${steps.map(stepRow).join("")}</div>
496
+ <button type="button" class="btn ghost sm add-row" data-action="add-step">${I.plus}Add step</button>
497
+ </section>
498
+ <section class="card card-pad stack">
499
+ <div class="section-title"><h2>Knowledge</h2><span>Facts Ava quotes when a question matches the keywords</span></div>
500
+ <div class="rows" id="pbKb">${(pb.knowledge || []).map(kbRow).join("")}</div>
501
+ <button type="button" class="btn ghost sm add-row" data-action="add-kb">${I.plus}Add fact</button>
502
+ </section>
503
+ </form>
504
+ <aside class="side-panel" aria-label="Check and save">
505
+ <div class="card"><div class="validity" id="pbValid" role="status" aria-live="polite">Checking…</div><ul class="errors" id="pbErrors" style="margin:0 16px 16px"></ul>
506
+ <div class="row" style="padding:0 16px 16px">
507
+ <button class="btn accent grow" data-action="save-pb">${I.check}Save playbook</button>
508
+ ${ed.existing ? `<a class="btn ghost" href="/app?mode=onboarding&amp;product=${encodeURIComponent(pb.id)}" target="_blank" rel="noopener">${I.mic}Test</a>` : ""}
509
+ </div></div>
510
+ <div class="card preview-card" id="pbPreview"></div>
511
+ <details class="card json"><summary>JSON file</summary><pre id="pbJson"></pre></details>
512
+ </aside>
513
+ </div>`;
514
+ }
515
+ const stepRow = (s, i = 0) => `<div class="erow"><span class="n">${i + 1}</span>
516
+ <input type="text" maxlength="60" aria-label="Step name" placeholder="Step name, e.g. Connect a source" value="${esc(s.title)}" data-k="title">
517
+ <textarea maxlength="400" rows="1" aria-label="How to do it" placeholder="How to do it, in plain words" data-k="how">${esc(s.how)}</textarea>
518
+ <button type="button" class="icon-btn danger" data-action="del-row" aria-label="Remove step">${I.trash}</button></div>`;
519
+ const kbRow = (k) => `<div class="erow kb">
520
+ <input type="text" maxlength="300" aria-label="Keywords" placeholder="Keywords: invoice export csv" value="${esc(k.keywords)}" data-k="keywords">
521
+ <textarea maxlength="1000" rows="2" aria-label="Answer" placeholder="The answer, checked against your current UI" data-k="text">${esc(k.text)}</textarea>
522
+ <button type="button" class="icon-btn danger" data-action="del-row" aria-label="Remove fact">${I.trash}</button></div>`;
523
+ function readEditor() {
524
+ const v = (id) => $(id).value.trim();
525
+ const steps = $$("#pbSteps .erow").map((r) => ({ title: $('[data-k="title"]', r).value.trim(), how: $('[data-k="how"]', r).value.trim() })).filter((s) => s.title || s.how);
526
+ const kb = $$("#pbKb .erow").map((r) => ({ keywords: $('[data-k="keywords"]', r).value.trim(), text: $('[data-k="text"]', r).value.trim() })).filter((k) => k.keywords || k.text);
527
+ const pb = { id: v("#pbId"), name: v("#pbName"), url: v("#pbUrl") || undefined, category: v("#pbCat") || undefined, accent: v("#pbAccentHex") || undefined,
528
+ tagline: v("#pbTagline") || undefined, firstWin: v("#pbWin") || undefined, optional: v("#pbOptional") || undefined,
529
+ // path[i] is how to do journey[i], so keep them aligned (a blank how-to falls back to the step name)
530
+ journey: steps.map((s) => s.title), path: steps.some((s) => s.how) ? steps.map((s) => s.how || s.title) : [], etaMin: v("#pbEta") ? Number(v("#pbEta")) : undefined, knowledge: kb };
531
+ if (!pb.path.length) delete pb.path;
532
+ return JSON.parse(JSON.stringify(pb));
533
+ }
534
+ let checkTimer;
535
+ function editorChanged() {
536
+ const pb = readEditor();
537
+ $("#pbJson").textContent = JSON.stringify(pb, null, 2);
538
+ const acc = /^#[0-9a-f]{3,6}$/i.test(pb.accent || "") ? pb.accent : "#4F46E5";
539
+ $("#pbPreview").style.setProperty("--pc", acc);
540
+ $("#pbPreview").innerHTML = `<div class="pv-top"><span>What customers see</span><b>${esc(pb.name ? `Get set up on ${pb.name}` : "Your product")}</b></div>
541
+ <ol class="pv-steps">${(pb.journey.length ? pb.journey : ["Your first step"]).map((s) => `<li>${esc(s || "…")}</li>`).join("")}</ol>`;
542
+ clearTimeout(checkTimer);
543
+ checkTimer = setTimeout(async () => {
544
+ try {
545
+ const r = await api("playbooks/check", { method: "POST", body: { playbook: pb } });
546
+ if (!ed.touched && !r.ok) { // a blank new playbook: guide, don't scold
547
+ $("#pbValid").className = "validity"; $("#pbValid").innerHTML = `${I.info}Add a product name and its steps to save`;
548
+ $("#pbErrors").innerHTML = ""; return;
549
+ }
550
+ $("#pbValid").className = "validity " + (r.ok ? "ok" : "bad");
551
+ $("#pbValid").innerHTML = r.ok ? `${I.check}Ready to save` : `${I.warn}${plural(r.errors.length, "thing")} to fix`;
552
+ $("#pbErrors").innerHTML = [...r.errors, ...r.warnings].map((x) => `<li>${esc(x)}</li>`).join("");
553
+ } catch {}
554
+ }, 250);
555
+ }
556
+ function wireEditor() {
557
+ const form = $("#pbForm"); if (!form) return;
558
+ form.addEventListener("input", (e) => {
559
+ if (e.target.id === "pbName" && !ed.idTouched) $("#pbId").value = slug(e.target.value);
560
+ if (e.target.id === "pbId") ed.idTouched = true;
561
+ ed.touched = true;
562
+ if (e.target.id === "pbAccent") $("#pbAccentHex").value = e.target.value.toUpperCase();
563
+ if (e.target.id === "pbAccentHex" && /^#[0-9a-f]{6}$/i.test(e.target.value)) $("#pbAccent").value = e.target.value;
564
+ editorChanged();
565
+ });
566
+ form.addEventListener("submit", (e) => e.preventDefault());
567
+ editorChanged();
568
+ }
569
+ function renumber() { $$("#pbSteps .erow .n").forEach((n, i) => { n.textContent = i + 1; }); }
570
+ async function savePlaybook(btn) {
571
+ ed.touched = true;
572
+ const pb = readEditor();
573
+ btn.disabled = true;
574
+ try {
575
+ await api(`playbooks/${encodeURIComponent(pb.id || "_")}`, { method: "PUT", body: { playbook: pb, previousId: ed.previousId } });
576
+ toast(`${pb.name} saved`);
577
+ go("#playbooks");
578
+ } catch (err) {
579
+ $("#pbValid").className = "validity bad"; $("#pbValid").innerHTML = `${I.warn}Not saved`;
580
+ $("#pbErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join("");
581
+ } finally { btn.disabled = false; }
582
+ }
583
+
584
+ // ═══════════ Settings ═══════════
585
+ // Edit in place; nothing is saved until "Save changes". Secrets are never sent to
586
+ // the page: they show as set or not, with Replace and Remove.
587
+ const SECTIONS = [
588
+ ["ai", "AI", "Which models Ava uses, through OpenRouter."],
589
+ ["voice", "Voice and speech", "How Ava sounds, and cloud speech-to-text."],
590
+ ["access", "Access", "Who can reach Ava, and where customers find it."],
591
+ ["limits", "Limits", "Spending caps and rate limits. Applied to new calls straight away."],
592
+ ["events", "Events", "Where Ava sends call events and handoff alerts."],
593
+ ["data", "Data and privacy", "What Ava keeps after a call."],
594
+ ["playbooks", "Playbooks", ""],
595
+ ];
596
+ let draft = {}, settingsData = null;
597
+ function settingRow(x) {
598
+ const id = `set-${x.key}`, pending = Object.prototype.hasOwnProperty.call(draft, x.key);
599
+ const badges = `${x.locked ? '<span class="badge mute">set by your host</span>' : ""}${x.pending ? '<span class="badge warn">restart to apply</span>' : x.restart ? '<span class="badge mute">needs a restart</span>' : ""}`;
600
+ let control;
601
+ if (x.secret) {
602
+ const state = pending ? (draft[x.key] === "" ? '<span class="badge err">will be removed</span>' : '<span class="badge live">new value</span>') : x.set ? `<span class="badge ok">set</span> <span class="mono meta" style="font-size:12.5px">${esc(x.value)}</span>` : '<span class="badge mute">not set</span>';
603
+ control = `<div class="secret-row"><span class="secret-state">${state}</span>
604
+ ${x.locked ? "" : `<span class="row" style="gap:6px">
605
+ <button type="button" class="btn ghost sm" data-action="secret-edit" data-key="${esc(x.key)}">${x.set ? "Replace" : "Add"}</button>
606
+ ${x.set && !pending ? `<button type="button" class="btn ghost sm" data-action="secret-clear" data-key="${esc(x.key)}">Remove</button>` : ""}
607
+ ${pending ? `<button type="button" class="btn ghost sm" data-action="secret-undo" data-key="${esc(x.key)}">Undo</button>` : ""}</span>`}</div>
608
+ <input id="${id}" class="mono-in" type="password" autocomplete="off" spellcheck="false" data-key="${esc(x.key)}" ${pending && draft[x.key] !== "" ? "" : "hidden"} value="${esc(pending ? draft[x.key] : "")}" placeholder="Paste the new value">`;
609
+ } else if (x.type === "enum") {
610
+ const v = pending ? draft[x.key] : x.value || x.default;
611
+ control = `<select id="${id}" data-key="${esc(x.key)}" ${x.locked ? "disabled" : ""}>${x.options.map((o) => `<option value="${esc(o)}" ${o === v ? "selected" : ""}>${esc(OPTION_LABEL[o] || o)}</option>`).join("")}</select>`;
612
+ } else {
613
+ const v = pending ? draft[x.key] : x.value;
614
+ control = `<input id="${id}" class="${x.type === "slug" || x.type === "url" || x.type === "urls" ? "mono-in" : ""}" type="${x.type === "number" ? "number" : "text"}" ${x.type === "number" ? `step="any"` : ""} data-key="${esc(x.key)}" value="${esc(v)}" placeholder="${esc(x.default || "")}" ${x.locked ? "disabled" : ""}>`;
615
+ }
616
+ return `<div class="srow ${pending ? "dirty" : ""}"><div class="slabel"><label for="${id}">${esc(x.label)}</label>${x.hint ? `<p class="hint">${esc(x.hint)}</p>` : ""}<div class="row" style="gap:6px;margin-top:4px">${badges}<span class="env">${esc(x.key)}</span></div></div><div class="sctl">${control}</div></div>`;
617
+ }
618
+ const OPTION_LABEL = { on: "On", off: "Off", kokoro: "Kokoro (offline, free)", elevenlabs: "ElevenLabs", hume: "Hume", browser: "The browser's voice", "127.0.0.1": "This computer only", "0.0.0.0": "Any network address", 1: "Yes", 0: "No" };
619
+ async function settings() {
620
+ const s = settingsData = await api("settings");
621
+ setStatus(s);
622
+ const by = (sec) => s.settings.filter((x) => x.section === sec);
623
+ const n = Object.keys(draft).length;
624
+ return `
625
+ ${head("Settings", "Settings", `Changes apply as soon as you save, except where marked. They're saved to <code>${esc(s.file)}</code>.`)}
626
+ <div class="stack">
627
+ ${SECTIONS.map(([sec, title, sub]) => `<section class="card" aria-labelledby="h-${sec}">
628
+ <div class="card-head"><div><h2 id="h-${sec}">${esc(title)}</h2>${sub ? `<p class="hint" style="margin-top:2px">${esc(sub)}</p>` : ""}</div>
629
+ ${sec === "ai" ? `<button type="button" class="btn accent sm" data-action="connect-or">${I.link}${s.mock ? "Connect OpenRouter" : "Reconnect OpenRouter"}</button>` : ""}
630
+ ${sec === "data" ? `<button type="button" class="btn ghost sm" data-action="delete-transcripts">${I.trash}Delete all transcripts</button>` : ""}
631
+ ${sec === "events" ? `<button type="button" class="btn ghost sm" data-action="test-hooks" ${s.events.endpoints || s.events.slack ? "" : "disabled"}>${I.send}Send test event</button>` : ""}</div>
632
+ ${by(sec).map(settingRow).join("")}</section>`).join("")}
633
+ <section class="card"><div class="card-head"><h2>Files</h2></div><dl class="dl">
634
+ <dt>Settings</dt><dd><span class="mono">${esc(s.file)}</span></dd>
635
+ <dt>Playbooks</dt><dd><span class="mono">${esc(s.files.playbooks)}</span></dd>
636
+ <dt>Share links</dt><dd><span class="mono">${esc(s.files.links)}</span></dd></dl></section>
637
+ </div>
638
+ <div class="savebar" id="saveBar" ${n ? "" : "hidden"} role="region" aria-label="Unsaved changes">
639
+ <span><b id="saveCount">${plural(n, "unsaved change")}</b></span><ul class="errors" id="saveErrors"></ul>
640
+ <button type="button" class="btn ghost sm" data-action="settings-discard">Discard</button>
641
+ <button type="button" class="btn accent sm" data-action="settings-save">${I.check}Save changes</button>
642
+ </div>`;
643
+ }
644
+ function markDraft(key, value) {
645
+ const x = settingsData.settings.find((s) => s.key === key);
646
+ const current = x.secret ? null : (x.value || (x.type === "enum" ? x.default : ""));
647
+ if (!x.secret && value === current) delete draft[key]; else draft[key] = value;
648
+ const n = Object.keys(draft).length;
649
+ $("#saveBar").hidden = !n; $("#saveCount").textContent = plural(n, "unsaved change");
650
+ $(`[data-key="${key}"]`)?.closest(".srow")?.classList.toggle("dirty", Object.prototype.hasOwnProperty.call(draft, key));
651
+ }
652
+ async function saveSettings(btn) {
653
+ if ("ACCESS_PASSWORD" in draft && !(await confirmBox("Change the access password?", draft.ACCESS_PASSWORD ? "Everyone, including you, signs in again with the new password. Share links keep working." : "Without a password, the dashboard and test page only work on this computer.", "Change it"))) return;
654
+ btn.disabled = true;
655
+ try {
656
+ const r = await api("settings", { method: "PUT", body: { values: draft } });
657
+ draft = {};
658
+ toast(r.restart.length ? `Saved. Restart Ava to apply: ${r.restart.join(", ")}` : r.changed.length ? "Saved and applied" : "Nothing changed");
659
+ render(false);
660
+ } catch (err) { $("#saveErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join(""); }
661
+ finally { btn.disabled = false; }
662
+ }
663
+ // calls filters (re-render the list, keep focus in the search box)
664
+ document.addEventListener("input", (e) => {
665
+ const f = e.target.dataset?.filter; if (!f) return;
666
+ if (f === "fq") callFilter.q = e.target.value; if (f === "fo") callFilter.outcome = e.target.value; if (f === "fp") callFilter.product = e.target.value; if (f === "fl") callFilter.link = e.target.value;
667
+ const pos = f === "fq" ? e.target.selectionStart : null;
668
+ render(false).then(() => { const el = document.getElementById(f); if (el) { el.focus(); if (pos != null) el.setSelectionRange(pos, pos); } });
669
+ });
670
+ document.addEventListener("keydown", (e) => { if (e.key === "Enter" && e.target.matches("tr.clickable")) go(e.target.dataset.href); });
671
+ document.addEventListener("change", (e) => { const k = e.target.dataset?.af; if (!k) return; if (k === "afp") af.product = e.target.value; if (k === "afl") af.link = e.target.value; render(false); });
672
+ // column tooltips (hover and keyboard focus)
673
+ const showTip = (col) => {
674
+ const tip = $("#ccTip"); if (!tip || !col) return;
675
+ const [day, calls, done, hand] = col.dataset.tip.split("|");
676
+ tip.innerHTML = `<b>${esc(day)}</b><span>${plural(Number(calls), "call")}</span><span>${done} finished · ${hand} asked for a person</span>`;
677
+ const box = col.closest(".colchart").getBoundingClientRect(), r = col.getBoundingClientRect();
678
+ tip.hidden = false;
679
+ tip.style.left = `${Math.min(box.width - tip.offsetWidth - 4, Math.max(4, r.left - box.left + r.width / 2 - tip.offsetWidth / 2))}px`;
680
+ };
681
+ document.addEventListener("mouseover", (e) => { const c = e.target.closest?.(".cc-col"); if (c) showTip(c); });
682
+ document.addEventListener("mouseout", (e) => { if (e.target.closest?.(".cc-col") && !e.relatedTarget?.closest?.(".cc-col")) { const t = $("#ccTip"); if (t) t.hidden = true; } });
683
+ document.addEventListener("focusin", (e) => { if (e.target.matches?.(".cc-col")) showTip(e.target); });
684
+ document.addEventListener("change", async (e) => {
685
+ const id = e.target.dataset?.roleOf; if (!id) return;
686
+ try { await api(`team/${id}`, { method: "PUT", body: { role: e.target.value } }); toast("Role changed. They'll sign in again."); }
687
+ catch (err) { toast(err.message, "err"); }
688
+ render(false);
689
+ });
690
+ document.addEventListener("submit", async (e) => {
691
+ if (e.target.id !== "firstOwner") return;
692
+ e.preventDefault();
693
+ try {
694
+ await api("team", { method: "POST", body: { name: $("#foName").value, email: $("#foEmail").value, password: $("#foPass").value } });
695
+ toast("Owner account created. You're signed in."); await loadMe(); render(true);
696
+ } catch (err) { $("#foErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join(""); }
697
+ });
698
+ document.addEventListener("input", (e) => { const k = e.target.dataset?.key; if (k && settingsData && e.target.closest("#page")) markDraft(k, e.target.value.trim()); });
699
+ document.addEventListener("change", (e) => { const k = e.target.dataset?.key; if (k && settingsData && e.target.tagName === "SELECT") markDraft(k, e.target.value); });
700
+ window.addEventListener("beforeunload", (e) => { if (Object.keys(draft).length) e.preventDefault(); });
701
+
702
+ // ═══════════ Analytics ═══════════
703
+ const af = { days: 30, product: "", link: "", table: false };
704
+ const pct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
705
+ const niceMax = (v) => { if (v <= 4) return 4; const p = 10 ** Math.floor(Math.log10(v)); return Math.ceil(v / p / (v / p > 5 ? 2 : 1)) * p * (v / p > 5 ? 2 : 1); };
706
+ async function analyticsPage() {
707
+ const q = new URLSearchParams({ days: af.days, ...(af.product && { product: af.product }), ...(af.link && { link: af.link }) });
708
+ const d = await api(`analytics?${q}`), t = d.totals;
709
+ const seg = [[7, "7 days"], [30, "30 days"], [90, "90 days"], [0, "All time"]].map(([v, l]) => `<button role="tab" aria-selected="${af.days === v}" data-af-days="${v}">${l}</button>`).join("");
710
+ const sel = (id, label, value, opts) => `<label class="sr" for="${id}">${label}</label><select id="${id}" data-af="${id}">${opts.map(([v, x]) => `<option value="${esc(v)}" ${v === value ? "selected" : ""}>${esc(x)}</option>`).join("")}</select>`;
711
+ const tile = (label, value, foot) => `<div class="card stat"><div class="label">${label}</div><div class="value">${value}</div><div class="foot">${foot}</div></div>`;
712
+ return `
713
+ ${head("Analytics", "How onboarding is going", "From finished calls: who gets to their first win, where people get stuck, and what each finished onboarding costs.")}
714
+ <div class="filters">
715
+ <div class="tabs" role="tablist" aria-label="Period" style="margin:0">${seg}</div>
716
+ ${sel("afp", "Product", af.product, [["", "All products"], ...d.options.products.map((p) => [p.id, p.name])])}
717
+ ${sel("afl", "Link", af.link, [["", "All links"], ["test", "Test calls"], ...d.options.links.map((l) => [l.id, l.name])])}
718
+ </div>
719
+ ${t.calls ? `
720
+ <div class="grid g5">
721
+ ${tile("Calls", t.calls.toLocaleString(), `${plural(t.onboarding, "onboarding")}`)}
722
+ ${tile("Finished onboarding", pct(t.completionRate), t.onboarding ? `${t.completed} of ${t.onboarding}` : "No onboarding calls")}
723
+ ${tile("Asked for a person", pct(t.handoffRate), "of all calls")}
724
+ ${tile("Typical call", `${t.medianMinutes}<small> min</small>`, "median length")}
725
+ ${tile("Cost per finished onboarding", t.costPerCompletion == null ? "—" : money(t.costPerCompletion), `${money(t.spend)} spent in total`)}
726
+ </div>
727
+ <section class="card" style="margin-top:16px" aria-labelledby="h-daily">
728
+ <div class="card-head"><div><h2 id="h-daily">Calls per day</h2></div><button class="btn ghost sm" data-action="af-table" aria-pressed="${af.table}">${af.table ? "Show chart" : "Show table"}</button></div>
729
+ ${af.table ? dailyTable(d.daily) : dailyChart(d.daily)}
730
+ </section>
731
+ ${d.funnels.map(funnelCard).join("")}
732
+ <section class="card" style="margin-top:16px" aria-labelledby="h-bylink"><div class="card-head"><h2 id="h-bylink">By link</h2></div>
733
+ <div class="table-wrap"><table><thead><tr><th>Link</th><th class="num">Calls</th><th class="num">Finished</th><th class="num">Asked for a person</th><th class="num">Spend</th><th class="num">Per finish</th></tr></thead>
734
+ <tbody>${d.links.map((l) => `<tr><td><b>${esc(l.name)}</b></td><td class="num">${l.calls}</td><td class="num">${pct(l.completionRate)}</td><td class="num">${pct(l.handoffRate)}</td><td class="num">${money(l.spend)}</td><td class="num">${l.costPerCompletion == null ? "—" : money(l.costPerCompletion)}</td></tr>`).join("")}</tbody></table></div>
735
+ </section>` : `<section class="card">${empty(I.headset, "No finished calls in this period", "Analytics fill in as calls end. Try a longer period, or another product or link.")}</section>`}`;
736
+ }
737
+ function dailyChart(daily) {
738
+ const max = niceMax(Math.max(1, ...daily.map((x) => x.calls)));
739
+ const ticks = [max, max / 2, 0];
740
+ const fmtDay = (d) => new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { month: "short", day: "numeric" });
741
+ const every = Math.ceil(daily.length / 8);
742
+ return `<div class="colchart" role="img" aria-label="Calls per day, ${esc(fmtDay(daily[0].date))} to ${esc(fmtDay(daily.at(-1).date))}. Use Show table for the numbers.">
743
+ <div class="cc-plot">
744
+ ${ticks.map((v) => `<div class="cc-grid" style="bottom:${(v / max) * 100}%"><span>${v}</span></div>`).join("")}
745
+ <div class="cc-cols">${daily.map((x, i) => `<div class="cc-col" tabindex="0" data-tip="${esc(fmtDay(x.date))}|${x.calls}|${x.completed}|${x.handoffs}"><i style="height:${(x.calls / max) * 100}%"></i>${i % every === 0 ? `<span class="cc-x">${esc(fmtDay(x.date))}</span>` : ""}</div>`).join("")}</div>
746
+ </div><div class="cc-tip" id="ccTip" hidden></div></div>`;
747
+ }
748
+ function dailyTable(daily) {
749
+ return `<div class="table-wrap" style="max-height:320px"><table><thead><tr><th>Day</th><th class="num">Calls</th><th class="num">Finished onboarding</th><th class="num">Asked for a person</th></tr></thead>
750
+ <tbody>${daily.slice().reverse().map((x) => `<tr><td>${esc(x.date)}</td><td class="num">${x.calls}</td><td class="num">${x.completed}</td><td class="num">${x.handoffs}</td></tr>`).join("")}</tbody></table></div>`;
751
+ }
752
+ function funnelCard(f) {
753
+ return `<section class="card" style="margin-top:16px" aria-labelledby="h-f-${esc(f.product.id)}">
754
+ <div class="card-head"><div><h2 id="h-f-${esc(f.product.id)}">${esc(f.product.name)}: where people get to</h2><p class="hint" style="margin-top:2px">Share of ${plural(f.calls, "onboarding call")} that got each step done</p></div></div>
755
+ <ol class="funnel">${f.steps.map((s, i) => `<li class="${f.stuck?.index === i ? "stuck" : ""}">
756
+ <span class="fn-step"><span class="mono">${i + 1}</span>${esc(s.title)}</span>
757
+ <span class="fn-bar" aria-hidden="true"><i style="width:${Math.max(0.5, (s.pct || 0) * 100)}%"></i></span>
758
+ <span class="fn-val mono">${pct(s.pct)}</span>
759
+ ${f.stuck?.index === i ? `<span class="fn-note">${I.warn}Biggest drop: ${pct(f.stuck.drop)} of calls stop at this step. Add a fact or a clearer how-to for it.</span>` : ""}
760
+ </li>`).join("")}</ol></section>`;
761
+ }
762
+
763
+ // ═══════════ who's signed in ═══════════
764
+ const RANK = { specialist: 1, editor: 2, owner: 3 };
765
+ let ME = { role: "owner", name: "You", id: "local" };
766
+ const can = (role) => RANK[ME.role] >= RANK[role];
767
+ const ROLE_TEXT = { owner: "Owner", editor: "Editor", specialist: "Specialist" };
768
+ const ROLE_HELP = { owner: "Everything, including settings and the team", editor: "Share links, playbooks and calls", specialist: "The inbox and calls: picks up customers who ask for a person" };
769
+ async function loadMe() {
770
+ try { ME = (await api("me")).user; } catch {}
771
+ $$("#nav a[data-role]").forEach((a) => { a.hidden = !can(a.dataset.role); });
772
+ const show = !ME.local;
773
+ $("#me").hidden = !show;
774
+ if (show) {
775
+ $("#meName").textContent = ME.name; $("#meRole").textContent = ME.shared ? "shared password" : ROLE_TEXT[ME.role];
776
+ $("#meAvatar").textContent = ME.name.split(/\s+/).map((w) => w[0]).slice(0, 2).join("").toUpperCase();
777
+ }
778
+ }
779
+ const noAccess = (what) => `${head("No access", "Not for your role", "")}${banner("info", `Only ${what} can open this page. Ask an owner if you need it.`)}`;
780
+
781
+ // ═══════════ Inbox: customers waiting for a person ═══════════
782
+ async function inbox() {
783
+ const d = await api("calls");
784
+ const rows = d.live.filter((c) => c.handoff).sort((a, b) => (a.handoff.pickedUpBy ? 1 : 0) - (b.handoff.pickedUpBy ? 1 : 0) || String(a.handoff.since).localeCompare(String(b.handoff.since)));
785
+ const alertsOn = alerts.enabled();
786
+ return `
787
+ ${head("Inbox", "Customers waiting for a person", "When a customer taps “Talk to a person”, they show up here. Pick one up to join their call: you see their screen, goal and progress, and Ava steps back.",
788
+ `<button class="btn ${alertsOn ? "ghost" : "accent"}" data-action="alerts">${alertsOn ? `${I.check}Alerts on` : "Turn on alerts"}</button>`)}
789
+ <section class="card">${rows.length ? rows.map((c) => {
790
+ const mine = c.handoff.pickedUpBy && c.handoff.pickedUpBy === ME.name, taken = !!c.handoff.pickedUpBy;
791
+ return `<div class="waiting ${taken ? "taken" : ""}"><span class="pulse" aria-hidden="true"></span>
792
+ <div class="waiting-main"><b>${esc(c.platform?.name || "Help with anything")}${c.link ? ` <span class="chip">${esc(c.link.name)}</span>` : ""}</b>
793
+ <span>${taken ? `Picked up by ${esc(c.handoff.pickedUpBy)}` : `Waiting ${esc(dur(since(c.handoff.since || c.startedAt)))}`}${c.currentStep ? ` · on “${esc(c.currentStep)}”` : ""}${c.workflow ? ` · ${c.workflow.done}/${c.workflow.total} steps` : ""}</span></div>
794
+ ${taken && !mine ? '<span class="badge ok">with a specialist</span>' : `<button class="btn ${mine ? "ghost" : "accent"} sm" data-action="pickup" data-id="${esc(c.id)}">${I.headset}${mine ? "Rejoin" : "Pick up"}</button>`}
795
+ </div>`;
796
+ }).join("") : empty(I.headset, "Nobody's waiting", "You'll see customers here the moment they ask for a person. Turn on alerts to get a sound and a notification, even when this tab is in the background.")}</section>`;
797
+ }
798
+ // A sound, a notification and a title badge when someone new starts waiting.
799
+ const alerts = {
800
+ enabled: () => { try { return localStorage.getItem("ava.alerts") === "on"; } catch { return false; } },
801
+ async enable() {
802
+ try { localStorage.setItem("ava.alerts", "on"); } catch {}
803
+ if ("Notification" in window && Notification.permission === "default") await Notification.requestPermission().catch(() => {});
804
+ this.beep();
805
+ },
806
+ disable() { try { localStorage.removeItem("ava.alerts"); } catch {} },
807
+ beep() {
808
+ try {
809
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
810
+ for (const [f, t] of [[880, 0], [1320, 0.16]]) {
811
+ const o = ctx.createOscillator(), g = ctx.createGain();
812
+ o.frequency.value = f; o.connect(g); g.connect(ctx.destination);
813
+ g.gain.setValueAtTime(0.0001, ctx.currentTime + t); g.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + t + 0.3);
814
+ o.start(ctx.currentTime + t); o.stop(ctx.currentTime + t + 0.32);
815
+ }
816
+ } catch {}
817
+ },
818
+ seen: null,
819
+ check(waiting) {
820
+ const ids = new Set(waiting.map((c) => c.id));
821
+ const fresh = this.seen ? waiting.filter((c) => !this.seen.has(c.id)) : [];
822
+ this.seen = ids;
823
+ document.title = waiting.length ? `(${waiting.length}) ${document.title.replace(/^\(\d+\) /, "")}` : document.title.replace(/^\(\d+\) /, "");
824
+ if (!fresh.length || !this.enabled()) return;
825
+ this.beep();
826
+ if ("Notification" in window && Notification.permission === "granted") {
827
+ const c = fresh[0];
828
+ const n = new Notification("A customer is waiting for a person", { body: `${c.platform?.name || "Ava"}${c.currentStep ? `: on “${c.currentStep}”` : ""}`, tag: c.id });
829
+ n.onclick = () => { window.focus(); go("#inbox"); };
830
+ }
831
+ },
832
+ };
833
+
834
+ // ═══════════ Team ═══════════
835
+ async function team() {
836
+ if (!can("owner")) return noAccess("owners");
837
+ const d = await api("team");
838
+ if (d.first) return `
839
+ ${head("Team", "Invite your team", "Give everyone their own sign-in, with what they're allowed to do. Start with your own owner account.")}
840
+ <section class="card card-pad" style="max-width:620px">
841
+ <h2>Create your owner account</h2>
842
+ <p class="hint" style="margin:6px 0 18px">After this, everyone signs in at <code>/login</code>, including on this computer.${ME.shared ? " The shared password you used keeps working as an owner login, for scripts." : ""}</p>
843
+ <form class="stack" id="firstOwner" novalidate>
844
+ <div class="fgrid">
845
+ <div class="field"><label for="foName">Your name</label><input id="foName" type="text" maxlength="60" autocomplete="name" required></div>
846
+ <div class="field"><label for="foEmail">Email</label><input id="foEmail" type="text" inputmode="email" autocomplete="email" required></div>
847
+ <div class="field wide"><label for="foPass">Password <span class="opt">(at least 10 characters)</span></label><input id="foPass" type="text" class="mono-in" autocomplete="new-password" required></div>
848
+ </div>
849
+ <ul class="errors" id="foErrors" role="alert"></ul>
850
+ <div class="row"><button class="btn accent" type="submit">${I.check}Create owner account</button></div>
851
+ </form>
852
+ </section>`;
853
+ return `
854
+ ${head("Team", "Team", "Everyone who can sign in, and what they may do.", `<button class="btn accent" data-action="new-member">${I.plus}Add someone</button>`)}
855
+ ${ME.shared ? banner("info", "You're signed in with the shared password, which counts as an owner. Sign in with your own account to show up by name in the inbox.") : ""}
856
+ <section class="card"><div class="table-wrap"><table>
857
+ <thead><tr><th>Person</th><th>Role</th><th>Last signed in</th><th></th></tr></thead>
858
+ <tbody>${d.users.map((u) => `<tr>
859
+ <td><div class="who"><span class="avatar" aria-hidden="true">${esc(u.name.split(/\s+/).map((w) => w[0]).slice(0, 2).join("").toUpperCase())}</span><div><b>${esc(u.name)}${u.id === d.me ? ' <span class="chip">you</span>' : ""}</b><div class="meta" style="font-size:13px">${esc(u.email)}</div></div></div></td>
860
+ <td><label class="sr" for="role-${esc(u.id)}">Role for ${esc(u.name)}</label><select id="role-${esc(u.id)}" data-role-of="${esc(u.id)}" style="width:auto;min-height:36px">${d.roles.map((r) => `<option value="${r}" ${r === u.role ? "selected" : ""}>${ROLE_TEXT[r]}</option>`).join("")}</select></td>
861
+ <td>${u.lastLoginAt ? esc(ago(u.lastLoginAt)) : '<span class="meta">never</span>'}</td>
862
+ <td style="text-align:right"><button class="btn ghost sm" data-action="reset-pw" data-id="${esc(u.id)}" data-name="${esc(u.name)}">Reset password</button>
863
+ <button class="icon-btn danger" data-action="remove-member" data-id="${esc(u.id)}" data-name="${esc(u.name)}" aria-label="Remove ${esc(u.name)}" title="Remove">${I.trash}</button></td>
864
+ </tr>`).join("")}</tbody></table></div></section>
865
+ <dl class="card dl" style="margin-top:16px">${Object.entries(ROLE_HELP).map(([r, t]) => `<dt>${ROLE_TEXT[r]}</dt><dd>${esc(t)}</dd>`).join("")}</dl>
866
+ ${await apiKeysCard()}`;
867
+ }
868
+ async function apiKeysCard() {
869
+ const k = await api("keys");
870
+ return `<section class="card" style="margin-top:16px" aria-labelledby="h-keys">
871
+ <div class="card-head"><div><h2 id="h-keys">API keys</h2><p class="hint" style="margin-top:2px">For scripts and tools such as n8n, Zapier or your own backend: everything the dashboard does, with the key's role. <a href="https://github.com/Mr-vero/ava-engine/blob/main/docs/API.md" target="_blank" rel="noopener">API guide</a></p></div>
872
+ <button class="btn ghost sm" data-action="new-key">${I.plus}New key</button></div>
873
+ ${k.keys.length ? `<div class="table-wrap"><table><thead><tr><th>Name</th><th>Role</th><th>Key</th><th>Last used</th><th></th></tr></thead><tbody>
874
+ ${k.keys.map((x) => `<tr><td><b>${esc(x.name)}</b>${x.createdBy ? `<div class="meta" style="font-size:12.5px">by ${esc(x.createdBy)}</div>` : ""}</td><td>${ROLE_TEXT[x.role]}</td><td class="mono" style="font-size:12.5px">${esc(x.prefix)}…</td><td>${x.lastUsedAt ? esc(ago(x.lastUsedAt)) : '<span class="meta">never</span>'}</td>
875
+ <td style="text-align:right"><button class="btn ghost sm" data-action="revoke-key" data-id="${esc(x.id)}" data-name="${esc(x.name)}">Revoke</button></td></tr>`).join("")}</tbody></table></div>`
876
+ : `<p class="hint" style="padding:16px 22px">No keys yet.</p>`}</section>`;
877
+ }
878
+ function newKeyDrawer() {
879
+ openDrawer("New API key", `
880
+ <div class="field"><label for="nkName">What will use it?</label><input id="nkName" type="text" maxlength="60" placeholder="e.g. n8n, our CRM sync"></div>
881
+ <div class="field"><label for="nkRole">Role</label><select id="nkRole">${["editor", "specialist", "owner"].map((r) => `<option value="${r}">${ROLE_TEXT[r]}: ${ROLE_HELP[r]}</option>`).join("")}</select><p class="hint">Give it the least it needs. Editor fits most automations (links, playbooks, calls).</p></div>
882
+ <ul class="errors" id="nkErrors" role="alert"></ul>
883
+ <div class="drawer-foot"><button type="button" class="btn ghost" data-action="close-drawer">Cancel</button><button type="submit" class="btn accent">${I.plus}Create key</button></div>`, async () => {
884
+ try {
885
+ const r = await api("keys", { method: "POST", body: { name: $("#nkName").value, role: $("#nkRole").value } });
886
+ $("#linkForm").innerHTML = `<p>Copy this key now: it won't be shown again. Send it as <code>Authorization: Bearer …</code>.</p>
887
+ <div class="secret-once"><code style="word-break:break-all">${esc(r.key)}</code><button type="button" class="btn ghost sm" data-action="copy" data-url="${esc(r.key)}">${I.copy}Copy</button></div>
888
+ <pre class="code">curl ${esc(location.origin)}/api/admin/overview \\\n -H "Authorization: Bearer ${esc(r.key.slice(0, 11))}…"</pre>
889
+ <div class="drawer-foot"><button type="button" class="btn accent" data-action="close-drawer">Done</button></div>`;
890
+ render(false);
891
+ } catch (err) { $("#nkErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join(""); }
892
+ });
893
+ }
894
+ function openDrawer(title, html, onSubmit) {
895
+ lastFocus = document.activeElement;
896
+ $("#drawerTitle").textContent = title;
897
+ $("#drawer .eyebrow").textContent = title.includes("account") ? "You" : "Team";
898
+ $("#linkForm").innerHTML = html;
899
+ $("#linkForm").onsubmit = async (e) => { e.preventDefault(); await onSubmit(); };
900
+ $("#scrim").hidden = false; $("#drawer").hidden = false;
901
+ $("#linkForm input, #linkForm button")?.focus();
902
+ }
903
+ function newMemberDrawer() {
904
+ openDrawer("Add someone", `
905
+ <div class="field"><label for="nmName">Name</label><input id="nmName" type="text" maxlength="60" required></div>
906
+ <div class="field"><label for="nmEmail">Email</label><input id="nmEmail" type="text" inputmode="email" autocomplete="off" required></div>
907
+ <div class="field"><span class="label" id="nmRoleL">Role</span><div class="choice" style="grid-template-columns:1fr" role="radiogroup" aria-labelledby="nmRoleL">
908
+ ${["specialist", "editor", "owner"].map((r, i) => `<label><input type="radio" name="nmRole" value="${r}" ${i === 0 ? "checked" : ""}><span><b>${ROLE_TEXT[r]}</b><span>${ROLE_HELP[r]}</span></span></label>`).join("")}</div></div>
909
+ <ul class="errors" id="nmErrors" role="alert"></ul><div id="nmDone"></div>
910
+ <div class="drawer-foot"><button type="button" class="btn ghost" data-action="close-drawer">Cancel</button><button type="submit" class="btn accent">${I.plus}Add to team</button></div>`, async () => {
911
+ try {
912
+ const r = await api("team", { method: "POST", body: { name: $("#nmName").value, email: $("#nmEmail").value, role: $('input[name="nmRole"]:checked').value } });
913
+ $("#linkForm").innerHTML = `<p>${esc(r.user.name)} can now sign in at <code>${esc(location.origin)}/login</code> with this one-time password. Share it privately: it won't be shown again, and they can change it under their account.</p>
914
+ <div class="secret-once"><code id="oncePw">${esc(r.password)}</code><button type="button" class="btn ghost sm" data-action="copy" data-url="${esc(r.password)}">${I.copy}Copy</button></div>
915
+ <div class="drawer-foot"><button type="button" class="btn accent" data-action="close-drawer">Done</button></div>`;
916
+ render(false);
917
+ } catch (err) { $("#nmErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join(""); }
918
+ });
919
+ }
920
+ function accountDrawer() {
921
+ const acct = !ME.local && !ME.shared;
922
+ openDrawer("Your account", `
923
+ <dl class="card dl"><dt>Name</dt><dd>${esc(ME.name)}</dd>${ME.email ? `<dt>Email</dt><dd>${esc(ME.email)}</dd>` : ""}<dt>Role</dt><dd>${ME.shared ? "Owner (shared password)" : ROLE_TEXT[ME.role]}</dd></dl>
924
+ ${acct ? `<h3 style="margin-top:6px">Change your password</h3>
925
+ <div class="field"><label for="acCur">Current password</label><input id="acCur" type="password" autocomplete="current-password"></div>
926
+ <div class="field"><label for="acNew">New password <span class="opt">(at least 10 characters)</span></label><input id="acNew" type="password" autocomplete="new-password"></div>
927
+ <ul class="errors" id="acErrors" role="alert"></ul>
928
+ <p class="hint">You'll stay signed in here and be signed out everywhere else.</p>
929
+ <div class="drawer-foot"><button type="button" class="btn ghost" data-action="close-drawer">Close</button><button type="submit" class="btn accent">Change password</button></div>`
930
+ : `<p class="hint">${ME.shared ? "The shared password is changed by an owner in Settings → Access." : "Nobody signs in on this Ava yet. Add accounts under Team."}</p><div class="drawer-foot"><button type="button" class="btn accent" data-action="close-drawer">Close</button></div>`}`, async () => {
931
+ if (!acct) return closeDrawer();
932
+ try { await api("me/password", { method: "POST", body: { current: $("#acCur").value, password: $("#acNew").value } }); closeDrawer(); toast("Password changed"); }
933
+ catch (err) { $("#acErrors").innerHTML = (err.errors || [err.message]).map((x) => `<li>${esc(x)}</li>`).join(""); }
934
+ });
935
+ }
936
+
937
+ // ═══════════ router ═══════════
938
+ const ROUTES = { overview, inbox, calls, analytics: analyticsPage, links, playbooks, team, settings };
939
+ let pollTimer = null, renderSeq = 0;
940
+ function go(hash) { if (location.hash === hash) render(true); else location.hash = hash; }
941
+ async function render(fresh = true) {
942
+ const [name, ...rest] = (location.hash.slice(1) || "overview").split("/");
943
+ const sub = rest.join("/");
944
+ const pageName = ROUTES[name] ? name : "overview";
945
+ const isEditor = pageName === "playbooks" && /^(new|edit|copy)/.test(sub);
946
+ $$("#nav a").forEach((a) => (a.dataset.page === pageName ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current")));
947
+ document.title = `Ava · ${pageName[0].toUpperCase() + pageName.slice(1)}`;
948
+ const seq = ++renderSeq;
949
+ if (fresh) {
950
+ const old = page(), el = document.createElement("div");
951
+ el.id = "page"; el.innerHTML = '<div class="skeleton"></div>';
952
+ old.replaceWith(el);
953
+ }
954
+ try {
955
+ const gate = { links: "editor", playbooks: "editor", settings: "owner" }[pageName];
956
+ const html = gate && !can(gate) ? noAccess(gate === "owner" ? "owners" : "owners and editors") : isEditor ? await editor(sub) : await ROUTES[pageName](sub);
957
+ if (seq !== renderSeq) return; // a newer navigation won
958
+ const y = window.scrollY;
959
+ page().innerHTML = html;
960
+ if (!fresh) window.scrollTo(0, y);
961
+ if (isEditor) wireEditor();
962
+ if (fresh) document.getElementById("main").focus({ preventScroll: true });
963
+ } catch (e) {
964
+ if (seq !== renderSeq) return;
965
+ page().innerHTML = banner("err", `<b>Couldn't load this page.</b> ${esc(e.message)}`);
966
+ }
967
+ clearTimeout(pollTimer);
968
+ if (livePage()) pollTimer = setTimeout(function tick() { if (!livePage()) return; if (document.hidden) pollTimer = setTimeout(tick, 4000); else render(false); }, 4000);
969
+ refreshBadge();
970
+ }
971
+ async function refreshBadge() {
972
+ try {
973
+ const d = await api("overview");
974
+ setStatus(d.status);
975
+ const b = $("#navLive"); b.hidden = !d.today.live; b.textContent = d.today.live; b.setAttribute("aria-label", `${d.today.live} live`);
976
+ const waiting = d.live.filter((c) => c.handoff && !c.handoff.pickedUpBy);
977
+ const w = $("#navWaiting"); w.hidden = !waiting.length; w.textContent = waiting.length; w.setAttribute("aria-label", `${waiting.length} waiting`);
978
+ alerts.check(waiting);
979
+ } catch {}
980
+ }
981
+
982
+ // ── actions (event delegation; no inline handlers under the CSP) ──
983
+ document.addEventListener("click", async (e) => {
984
+ const t = e.target.closest("[data-action], [data-href], [data-af-days]");
985
+ if (!t) return;
986
+ if (t.dataset.href) { go(t.dataset.href); return; }
987
+ if (t.dataset.afDays != null) { af.days = Number(t.dataset.afDays); render(false); return; }
988
+ const a = t.dataset.action;
989
+ if (a === "new-link") openLinkDrawer();
990
+ if (a === "close-drawer") closeDrawer();
991
+ if (a === "copy") copy(t.dataset.url);
992
+ if (a === "toggle-link") {
993
+ const l = linksCache.links.find((x) => x.id === t.dataset.id);
994
+ try { await api(`links/${l.id}`, { method: "PUT", body: { ...linkBody(l), enabled: !l.enabled } }); toast(l.enabled ? "Link paused" : "Link resumed"); render(false); }
995
+ catch (err) { toast(err.message, "err"); }
996
+ }
997
+ if (a === "delete-link") {
998
+ const l = linksCache.links.find((x) => x.id === t.dataset.id);
999
+ if (await confirmBox(`Delete “${l.name}”?`, "The link stops working right away, including for anyone on a call from it. This can't be undone.")) {
1000
+ try { await api(`links/${l.id}`, { method: "DELETE" }); toast("Link deleted"); render(false); } catch (err) { toast(err.message, "err"); }
1001
+ }
1002
+ }
1003
+ if (a === "delete-pb") {
1004
+ if (await confirmBox(`Delete ${t.dataset.name}?`, "Its file is removed and new calls can't use it. Calls already running aren't affected.")) {
1005
+ try { await api(`playbooks/${encodeURIComponent(t.dataset.id)}`, { method: "DELETE" }); toast("Playbook deleted"); render(false); } catch (err) { toast(err.message, "err"); }
1006
+ }
1007
+ }
1008
+ if (a === "add-step") { if ($$("#pbSteps .erow").length >= 10) return toast("Up to 10 steps", "err"); $("#pbSteps").insertAdjacentHTML("beforeend", stepRow({ title: "", how: "" }, $$("#pbSteps .erow").length)); $("#pbSteps .erow:last-child input").focus(); editorChanged(); }
1009
+ if (a === "add-kb") { $("#pbKb").insertAdjacentHTML("beforeend", kbRow({ keywords: "", text: "" })); $("#pbKb .erow:last-child input").focus(); editorChanged(); }
1010
+ if (a === "del-row") { t.closest(".erow").remove(); renumber(); editorChanged(); }
1011
+ if (a === "save-pb") savePlaybook(t);
1012
+ if (a === "export-calls") exportCalls();
1013
+ if (a === "embed-link") embedDrawer(linksCache.links.find((x) => x.id === t.dataset.id));
1014
+ if (a === "af-table") { af.table = !af.table; render(false); }
1015
+ if (a === "draft-ai") draftDrawer();
1016
+ if (a === "lib-preview") libPreview(t.dataset.id);
1017
+ if (a === "lib-install") {
1018
+ if (t.dataset.overwrite && !(await confirmBox("Replace your playbook?", "Your own playbook with this id is replaced by the library's version.", "Replace"))) return;
1019
+ t.disabled = true;
1020
+ try { const r = await api(`library/${t.dataset.id}/install`, { method: "POST", body: { overwrite: !!t.dataset.overwrite } }); closeDrawer(); toast(`${r.playbook.name} installed. Make it yours, then share a link`); render(false); }
1021
+ catch (err) { toast(err.message, "err"); t.disabled = false; }
1022
+ }
1023
+ if (a === "alerts") { if (alerts.enabled()) alerts.disable(); else await alerts.enable(); render(false); }
1024
+ if (a === "pickup") {
1025
+ const tab = window.open("about:blank", "_blank");
1026
+ try { const r = await api(`calls/${t.dataset.id}/pickup`, { method: "POST", body: {} }); if (tab) { tab.opener = null; tab.location = r.joinPath; } else location.assign(r.joinPath); render(false); }
1027
+ catch (err) { tab?.close(); toast(err.message, "err"); render(false); }
1028
+ }
1029
+ if (a === "new-member") newMemberDrawer();
1030
+ if (a === "new-key") newKeyDrawer();
1031
+ if (a === "revoke-key") {
1032
+ if (await confirmBox(`Revoke “${t.dataset.name}”?`, "Anything using this key stops working right away.", "Revoke")) {
1033
+ try { await api(`keys/${t.dataset.id}`, { method: "DELETE" }); toast("Key revoked"); render(false); } catch (err) { toast(err.message, "err"); }
1034
+ }
1035
+ }
1036
+ if (a === "account") accountDrawer();
1037
+ if (a === "signout") { await fetch("/api/auth/logout", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); location.replace("/login"); }
1038
+ if (a === "reset-pw") {
1039
+ if (await confirmBox(`Reset ${t.dataset.name}'s password?`, "They're signed out everywhere and get a new one-time password from you.", "Reset")) {
1040
+ try { const r = await api(`team/${t.dataset.id}/password`, { method: "POST", body: {} }); openDrawer("New password", `<p>Give ${esc(t.dataset.name)} this one-time password privately. It won't be shown again.</p><div class="secret-once"><code>${esc(r.password)}</code><button type="button" class="btn ghost sm" data-action="copy" data-url="${esc(r.password)}">${I.copy}Copy</button></div><div class="drawer-foot"><button type="button" class="btn accent" data-action="close-drawer">Done</button></div>`, closeDrawer); }
1041
+ catch (err) { toast(err.message, "err"); }
1042
+ }
1043
+ }
1044
+ if (a === "remove-member") {
1045
+ if (await confirmBox(`Remove ${t.dataset.name}?`, "They can't sign in any more. Their past pickups stay in the call history.", "Remove")) {
1046
+ try { await api(`team/${t.dataset.id}`, { method: "DELETE" }); toast(`${t.dataset.name} removed`); render(false); } catch (err) { toast(err.message, "err"); }
1047
+ }
1048
+ }
1049
+ if (a === "delete-call") {
1050
+ if (await confirmBox("Delete this call?", "Its numbers and transcript are removed for good. Use this for a customer's deletion request.")) {
1051
+ try { await api(`calls/${t.dataset.id}`, { method: "DELETE" }); toast("Call deleted"); go("#calls/history"); } catch (err) { toast(err.message, "err"); }
1052
+ }
1053
+ }
1054
+ if (a === "delete-transcripts") {
1055
+ if (await confirmBox("Delete every transcript?", "All saved conversations and recaps are removed for good. Call numbers stay.", "Delete all")) {
1056
+ try { await api("transcripts", { method: "DELETE" }); toast("Transcripts deleted"); } catch (err) { toast(err.message, "err"); }
1057
+ }
1058
+ }
1059
+ if (a === "secret-edit") { const i = $(`#set-${t.dataset.key}`); i.hidden = false; i.focus(); }
1060
+ if (a === "secret-clear") { draft[t.dataset.key] = ""; render(false); }
1061
+ if (a === "secret-undo") { delete draft[t.dataset.key]; render(false); }
1062
+ if (a === "settings-discard") { draft = {}; render(false); }
1063
+ if (a === "settings-save") saveSettings(t);
1064
+ if (a === "connect-or") {
1065
+ const tab = window.open("about:blank", "_blank");
1066
+ try { const { url } = await api("openrouter/start", { method: "POST", body: {} }); if (tab) { tab.opener = null; tab.location = url; } else location.assign(url); toast("Approve in the OpenRouter tab, then come back"); }
1067
+ catch (err) { tab?.close(); toast(err.message, "err"); }
1068
+ }
1069
+ if (a === "test-hooks") {
1070
+ t.disabled = true;
1071
+ try { const r = await api("webhooks/test", { method: "POST", body: {} }); toast(`Delivered to ${r.delivered} of ${r.sent}`, r.delivered === r.sent ? "" : "err"); }
1072
+ catch (err) { toast(err.message, "err"); } finally { t.disabled = false; }
1073
+ }
1074
+ });
1075
+ $("#scrim").addEventListener("click", closeDrawer);
1076
+ $("#drawerClose").addEventListener("click", closeDrawer);
1077
+ document.addEventListener("keydown", (e) => {
1078
+ if (e.key === "Escape" && !$("#drawer").hidden) closeDrawer();
1079
+ if (e.key === "Tab" && !$("#drawer").hidden) { // keep focus inside the drawer
1080
+ const f = $$("#drawer button, #drawer input, #drawer select, #drawer textarea, #drawer a[href]").filter((x) => !x.disabled && x.offsetParent);
1081
+ if (!f.length) return;
1082
+ if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f.at(-1).focus(); }
1083
+ else if (!e.shiftKey && document.activeElement === f.at(-1)) { e.preventDefault(); f[0].focus(); }
1084
+ }
1085
+ });
1086
+ window.addEventListener("hashchange", () => { if (!/^#links\/(new|edit)/.test(location.hash) || !$("#page .lcard, #page .empty")) render(true); else render(false); });
1087
+ // Only the live pages refresh themselves; never re-render a form someone is filling in.
1088
+ const livePage = () => ["overview", "calls", "inbox"].includes((location.hash.slice(1) || "overview").split("/")[0]) && !/^#calls\/[0-9a-f-]{36}/.test(location.hash);
1089
+ document.addEventListener("visibilitychange", () => {
1090
+ if (document.hidden) return;
1091
+ if (livePage()) render(false);
1092
+ else if (location.hash.startsWith("#settings") && !Object.keys(draft).length) render(false); // e.g. back from Connect OpenRouter
1093
+ });
1094
+ loadMe().then(() => render(true));
1095
+ setInterval(() => { if (!livePage()) refreshBadge(); }, 5000); // the inbox badge and alerts, on every page
1096
+ })();