1import { api, confirmDialog, fmtDate, fmtNum, h, mount, parseCsv, toast, toastError } from "../lib.js"; 2import { upgradePrompt } from "../components.js"; 3import { can, navigate, refreshMe, state } from "../main.js"; 4 5const ROLES = ["owner", "admin", "analyst", "seller", "viewer"]; 6const ROLE_HELP = { 7 owner: "Everything, including billing", 8 admin: "Everything except managing billing", 9 analyst: "Read books, lineage and export", 10 seller: "Reveal, enrich, feedback, export", 11 viewer: "Read-only; emails and phones hidden", 12}; 13 14export async function teamView() { 15 const data = await api("/api/team"); 16 const manage = can("team.manage"); 17 const email = h("input", { class: "input", type: "email", placeholder: "[email protected]", required: true }); 18 const role = h("select", { class: "select" }, ROLES.filter((r) => state.me.role === "owner" || !["owner", "admin"].includes(r)) 19 .map((r) => h("option", { value: r, selected: r === "seller" ? "selected" : null }, `${r}: ${ROLE_HELP[r]}`))); 20 const inviteForm = h("form", { class: "card stack", onsubmit: async (e) => { 21 e.preventDefault(); 22 try { 23 await api("/api/team/invites", { method: "POST", body: { email: email.value, role: role.value } }); 24 toast(`Invite sent to ${email.value}.`); 25 navigate("/app/team", { replace: true }); 26 } catch (err) { toastError(err); } 27 } }, h("h3", {}, "Invite a teammate"), h("div", { class: "grid-2" }, email, role), 28 h("div", { class: "row" }, h("span", { class: "spacer small muted" }, "Invites expire after 7 days."), h("button", { class: "btn primary" }, "Send invite"))); 29 30 const members = h("div", { class: "card" }, h("table", { class: "plain" }, 31 h("thead", {}, h("tr", {}, h("th", {}, "Member"), h("th", {}, "Role"), h("th", {}, "Last sign-in"), h("th", {}))), 32 h("tbody", {}, data.members.map((m) => { 33 const me = m.user_id === state.me.user.id; 34 const roleCell = manage && !me 35 ? h("select", { class: "select", onchange: async (e) => { 36 try { await api(`/api/team/members/${m.user_id}`, { method: "PATCH", body: { role: e.target.value } }); toast("Role updated."); } 37 catch (err) { toastError(err); navigate("/app/team", { replace: true }); } 38 } }, ROLES.map((r) => h("option", { value: r, selected: r === m.role ? "selected" : null }, r))) 39 : h("span", { class: "chip" }, m.role); 40 return h("tr", {}, 41 h("td", {}, h("strong", {}, m.display_name || m.email), me ? " (you)" : "", h("div", { class: "small muted" }, m.email)), 42 h("td", {}, roleCell), 43 h("td", { class: "small" }, m.last_login_at ? fmtDate(m.last_login_at, true) : "Never"), 44 h("td", {}, (manage && !me) || me ? h("button", { class: "btn sm ghost danger", onclick: async () => { 45 if (!(await confirmDialog(me ? "Leave this workspace?" : `Remove ${m.email}?`, "They lose access immediately.", me ? "Leave" : "Remove", true))) return; 46 try { 47 await api(`/api/team/members/${m.user_id}`, { method: "DELETE" }); 48 if (me) location.assign("/app/login"); else navigate("/app/team", { replace: true }); 49 } catch (err) { toastError(err); } 50 } }, me ? "Leave" : "Remove") : null)); 51 })))); 52 53 const invites = manage && data.invites.length ? h("div", { class: "card" }, h("h3", {}, "Pending invites"), 54 h("table", { class: "plain" }, h("tbody", {}, data.invites.map((i) => h("tr", {}, 55 h("td", {}, i.email), h("td", {}, h("span", { class: "chip" }, i.role)), h("td", { class: "small muted" }, `expires ${fmtDate(i.expires_at)}`), 56 h("td", {}, h("button", { class: "btn sm ghost", onclick: async () => { 57 try { await api(`/api/team/invites/${i.id}`, { method: "DELETE" }); navigate("/app/team", { replace: true }); } catch (err) { toastError(err); } 58 } }, "Revoke"))))))) : null; 59 60 return h("div", { class: "stack-lg" }, 61 h("div", { class: "page-head" }, h("div", {}, h("div", { class: "eyebrow" }, "Team"), h("h1", {}, state.me.tenant.name))), 62 manage ? inviteForm : null, members, invites); 63} 64 65export async function auditView() { 66 const data = await api("/api/audit"); 67 return h("div", { class: "stack-lg" }, 68 h("div", { class: "page-head" }, h("div", {}, h("div", { class: "eyebrow" }, "Audit log"), h("h1", {}, "Who did what"))), 69 h("div", { class: "card" }, data.entries.length ? h("table", { class: "plain" }, 70 h("thead", {}, h("tr", {}, h("th", {}, "When"), h("th", {}, "Who"), h("th", {}, "Action"), h("th", {}, "Details"))), 71 h("tbody", {}, data.entries.map((e) => h("tr", {}, 72 h("td", { class: "small" }, fmtDate(e.at, true)), h("td", {}, e.user_email || "system"), 73 h("td", {}, h("span", { class: "chip" }, e.action)), 74 h("td", { class: "small muted mono" }, Object.keys(e.meta || {}).length ? JSON.stringify(e.meta) : ""))))) 75 : h("p", { class: "muted" }, "Nothing yet."))); 76} 77 78export async function settingsView() { 79 const me = state.me; 80 const name = h("input", { class: "input", value: me.user.display_name || "", placeholder: "Your name" }); 81 const workspace = h("input", { class: "input", value: me.tenant.name }); 82 return h("div", { class: "stack-lg" }, 83 h("div", { class: "page-head" }, h("div", {}, h("div", { class: "eyebrow" }, "Settings"), h("h1", {}, "Profile and workspace"))), 84 h("form", { class: "card stack", onsubmit: async (e) => { 85 e.preventDefault();
86 try { await api("/api/me", { method: "PATCH", body: { display_name: name.value } }); await refreshMe(); toast("Saved."); } catch (err) { toastError(err); } 87 } }, h("h3", {}, "Your profile"), h("label", { class: "field" }, "Name", name), 88 h("p", { class: "small muted" }, `Signed in as ${me.user.email}`), h("div", {}, h("button", { class: "btn" }, "Save"))), 89 can("tenant.manage") ? h("form", { class: "card stack", onsubmit: async (e) => { 90 e.preventDefault(); 91 try { await api("/api/tenant", { method: "PATCH", body: { name: workspace.value } }); location.reload(); } catch (err) { toastError(err); } 92 } }, h("h3", {}, "Workspace"), h("label", { class: "field" }, "Workspace name", workspace), h("div", {}, h("button", { class: "btn" }, "Save"))) : null, 93 can("tenant.manage") ? await apiKeysCard() : null, 94 can("campaign.read") ? await socialCard() : null, 95 can("campaign.read") ? await crmCard() : null, 96 can("tenant.manage") ? await integrationsCard() : null, 97 can("campaign.run") ? await suppressionsCard() : null, 98 h("div", { class: "card stack" }, h("h3", {}, "Your data"), 99 h("p", { class: "muted small" }, "Deleting a campaign removes your hunt: seller profile, ICP, book, scores and notes. To close the whole workspace and erase its data, email [email protected] from an owner account."))); 100} 101 102// --------------------------------------------------------------------------- 103// BYO provider keys: the email waterfall runs site â pattern â your providers, in your order. 104// --------------------------------------------------------------------------- 105const PROVIDER_HELP = { 106 hunter: "Email Finder API. Charged to your Hunter plan; we charge 1 enrich step only when it finds an address.", 107 apollo: "People Match API (work emails only, no personal emails or phones).", 108 prospeo: "Email Finder API with verification status.", 109}; 110 111async function integrationsCard() { 112 const card = h("div", { class: "card stack" }); 113 async function draw() { 114 const data = await api("/api/integrations"); 115 mount(card, 116 h("div", { class: "card-head" }, h("h3", {}, "Email waterfall · your provider keys"), h("span", { class: "spacer" }), 117 h("span", { class: "small muted" }, "Order: ", data.waterfall.map((s, i) => [i ? " â " : "", h("span", { class: "chip mini" }, s)]))), 118 h("p", { class: "muted small" }, "When a company's site doesn't publish the contact's address and no pattern can be learned, we ask your providers in priority order and stop at the first hit. The same keys power Find people (people at an account whose titles match your ICP) when you ask for it from the lead book or a dossier. Keys are encrypted at rest and never shown again; results are private to this workspace."), 119 h("div", { class: "stack" }, data.providers.map((p) => { 120 const key = h("input", { class: "input", type: "password", placeholder: p.configured ? `Configured (${p.key_hint}) · paste a new key to replace` : "Paste API key", autocomplete: "off" }); 121 const priority = h("input", { class: "input", type: "number", min: 1, max: 999, value: p.priority, "aria-label": "Priority" }); 122 priority.style.width = "90px"; 123 const enabled = h("input", { type: "checkbox", checked: p.configured ? p.enabled : true, "aria-label": "Enabled" }); 124 const save = h("button", { class: "btn sm", onclick: async () => { 125 try { 126 await api(`/api/integrations/${p.provider}`, { method: "PUT", body: { api_key: key.value.trim() || null, enabled: enabled.checked, priority: Number(priority.value) || 100 } }); 127 toast(`${p.label} saved.`); draw(); 128 } catch (err) { toastError(err); } 129 } }, p.configured ? "Save" : "Add"); 130 const remove = p.configured ? h("button", { class: "btn sm ghost danger", onclick: async () => { 131 if (!(await confirmDialog(`Remove ${p.label} key?`, "Existing results stay; new enrichments skip this provider.", "Remove", true))) return; 132 try { await api(`/api/integrations/${p.provider}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); } 133 } }, "Remove") : null; 134 return h("div", { class: "provider" }, 135 h("div", { class: "row wrap" }, h("strong", {}, p.label), p.configured ? h("span", { class: `chip mini ${p.enabled ? "teal" : ""}` }, p.enabled ? "on" : "off") : h("span", { class: "chip mini sentinel" }, "not set"), 136 p.last_error ? h("span", { class: "chip mini danger", title: p.last_error }, "last call failed") : null, 137 p.last_used_at ? h("span", { class: "small muted" }, `used ${fmtDate(p.last_used_at, true)}`) : null),
138 h("div", { class: "small muted" }, PROVIDER_HELP[p.provider] || ""), 139 h("div", { class: "row wrap" }, h("div", { class: "spacer" }, key), h("label", { class: "row small" }, "Priority ", priority), h("label", { class: "row small" }, enabled, " enabled"), save, remove)); 140 }))); 141 } 142 await draw(); 143 return card; 144} 145 146// --------------------------------------------------------------------------- 147// Suppression list: customers, partners, do-not-contact. Never shown in any book again. 148// --------------------------------------------------------------------------- 149async function suppressionsCard() { 150 const card = h("div", { class: "card stack" }); 151 async function draw() { 152 const data = await api("/api/suppressions"); 153 const input = h("textarea", { class: "textarea", rows: 3, placeholder: "Domains, one per line or comma-separated. Paste a CSV column too." }); 154 const reason = h("input", { class: "input", placeholder: "Reason (optional): existing customer, partner, competitorâ¦" }); 155 const file = h("input", { type: "file", accept: ".csv,.txt", class: "hidden" }); 156 file.addEventListener("change", () => { 157 const f = file.files[0]; if (!f) return; 158 const r = new FileReader(); 159 r.onload = () => { input.value = parseCsv(String(r.result || "")).map((row) => row.find((v) => /\.[a-z]{2,}/i.test(v)) || "").filter(Boolean).join("\n"); }; 160 r.readAsText(f); 161 }); 162 const add = h("button", { class: "btn", onclick: async () => { 163 const domains = input.value.split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean); 164 if (!domains.length) return; 165 try { 166 const res = await api("/api/suppressions", { method: "POST", body: { domains, reason: reason.value.trim() || null } }); 167 toast(`Suppressed ${res.added} domain${res.added === 1 ? "" : "s"}${res.rejected_existing ? `; ${res.rejected_existing} existing rows marked rejected` : ""}.`); 168 draw(); 169 } catch (err) { toastError(err); } 170 } }, "Suppress"); 171 mount(card, 172 h("div", { class: "card-head" }, h("h3", {}, "Suppression list"), h("span", { class: "spacer" }), h("span", { class: "small muted" }, `${fmtNum(data.total)} domains`)), 173 h("p", { class: "muted small" }, "Companies you never want in a lead book: existing customers, partners, do-not-contact. Graph search, AI discovery and imports skip them; rows already in your books are marked rejected."), 174 input, h("div", { class: "row wrap" }, h("div", { class: "spacer" }, reason), h("button", { class: "btn sm ghost", onclick: () => file.click() }, "Upload CSV"), file, add), 175 data.domains.length ? h("div", { class: "chips" }, data.domains.slice(0, 60).map((d) => h("span", { class: "chip", title: d.reason || "" }, d.domain, 176 h("button", { class: "chip-x", "aria-label": `Remove ${d.domain}`, onclick: async () => { 177 try { await api(`/api/suppressions/${encodeURIComponent(d.domain)}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); } 178 } }, "Ã"))), data.domains.length > 60 ? h("span", { class: "small muted" }, `+${data.domains.length - 60} more`) : null) : null); 179 } 180 await draw(); 181 return card; 182} 183 184// --------------------------------------------------------------------------- 185// API keys: agents (Claude, ChatGPT, Cursorâ¦) and scripts. Shown once, stored hashed. 186// --------------------------------------------------------------------------- 187async function apiKeysCard() { 188 const card = h("div", { class: "card stack" }); 189 async function draw(freshKey) { 190 const data = await api("/api/keys"); 191 const name = h("input", { class: "input", placeholder: "Key name, e.g. Claude research agent", maxlength: 80 }); 192 const role = h("select", { class: "select" }, 193 h("option", { value: "seller" }, "seller: reveal, enrich, export"), 194 h("option", { value: "analyst" }, "analyst: read + export only"), 195 state.me.role === "owner" ? h("option", { value: "admin" }, "admin: also create campaigns, manage team") : null); 196 role.style.width = "auto"; 197 const create = h("button", { class: "btn primary", onclick: async () => { 198 if (!name.value.trim()) { toast("Give the key a name.", "error"); return; }
199 try { const res = await api("/api/keys", { method: "POST", body: { name: name.value.trim(), role: role.value } }); draw(res); } 200 catch (err) { toastError(err); } 201 } }, "Create key"); 202 const mcpConfig = JSON.stringify({ mcpServers: { visibletrust: { url: data.mcp_url, headers: { Authorization: "Bearer <your key>" } } } }, null, 2); 203 mount(card, 204 h("div", { class: "card-head" }, h("h3", {}, "API keys · agents and the MCP server"), h("span", { class: "spacer" }), 205 h("a", { class: "small", href: "/api/docs", target: "_blank", rel: "noopener" }, "OpenAPI docs")), 206 h("p", { class: "muted small" }, "A key acts as a member of this workspace with a fixed role: same metering, same permissions, same row-level security. Point Claude, ChatGPT, Cursor or your own agent at the MCP server and it can run the whole workflow: learn â ICP â find â reveal â enrich â dossier."), 207 freshKey ? h("div", { class: "banner teal stack" }, 208 h("div", {}, h("strong", {}, `New key "${freshKey.prefix}"`), " â copy it now, it won't be shown again."), 209 h("code", { class: "keybox", tabindex: 0, onclick: (e) => { const r = document.createRange(); r.selectNodeContents(e.currentTarget); getSelection().removeAllRanges(); getSelection().addRange(r); } }, freshKey.key), 210 h("div", { class: "row wrap" }, h("button", { class: "btn sm", onclick: async () => { try { await navigator.clipboard.writeText(freshKey.key); toast("Copied."); } catch { toast("Select and copy the key manually."); } } }, "Copy key"))) : null, 211 h("div", { class: "row wrap" }, h("div", { class: "spacer" }, name), role, create), 212 data.keys.length ? h("table", { class: "plain" }, 213 h("thead", {}, h("tr", {}, h("th", {}, "Name"), h("th", {}, "Key"), h("th", {}, "Role"), h("th", {}, "Last used"), h("th", {}))), 214 h("tbody", {}, data.keys.map((k) => h("tr", { class: k.revoked_at ? "muted" : "" }, 215 h("td", {}, k.name), h("td", { class: "mono" }, k.prefix), h("td", {}, h("span", { class: "chip" }, k.role)), 216 h("td", { class: "small" }, k.revoked_at ? `revoked ${fmtDate(k.revoked_at)}` : k.last_used_at ? fmtDate(k.last_used_at, true) : "never"), 217 h("td", {}, k.revoked_at ? null : h("button", { class: "btn sm ghost danger", onclick: async () => { 218 if (!(await confirmDialog(`Revoke "${k.name}"?`, "Anything using it stops working immediately.", "Revoke", true))) return; 219 try { await api(`/api/keys/${k.id}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); } 220 } }, "Revoke")))))) : h("p", { class: "muted small" }, "No keys yet."), 221 h("details", { class: "small" }, h("summary", {}, "Connect an MCP client"), 222 h("p", { class: "muted" }, `MCP endpoint: ${data.mcp_url} (Streamable HTTP, bearer auth). Example client config:`), 223 h("pre", { class: "codeblock" }, mcpConfig))); 224 } 225 await draw(); 226 return card; 227} 228 229// --------------------------------------------------------------------------- 230// CRM connections + Slack alerts 231// --------------------------------------------------------------------------- 232async function crmCard() { 233 const card = h("div", { class: "card stack" }); 234 const manage = can("tenant.manage"); 235 async function draw() { 236 const [data, slack] = await Promise.all([api("/api/crm"), api("/api/alerts/slack")]); 237 const params = new URLSearchParams(location.search); 238 const banner = params.get("crm") === "error" ? h("div", { class: "banner danger" }, "The CRM connection didn't complete. Try again.") 239 : params.get("crm") ? h("div", { class: "banner teal" }, `${params.get("crm")} connected.`) : null; 240 const rows = data.providers.map((p) => { 241 const token = p.token_paste && manage ? h("input", { class: "input", type: "password", placeholder: p.connected ? "Paste a new private-app token to replace" : "Paste a HubSpot private-app token", autocomplete: "off" }) : null; 242 const actions = []; 243 if (manage && p.token_paste) actions.push(h("button", { class: "btn sm", onclick: async () => { 244 if (!token.value.trim()) return; 245 try { const res = await api("/api/crm/hubspot/token", { method: "PUT", body: { token: token.value.trim() } }); toast(`HubSpot connected (${res.account}).`); draw(); } 246 catch (err) { toastError(err); } 247 } }, p.connected ? "Replace token" : "Connect with token")); 248 if (manage) actions.push(h("a", { class: `btn sm ${p.oauth_available ? "" : "disabled"}`, href: p.oauth_available ? `/api/crm/${p.provider}/connect` : "#", 249 title: p.oauth_available ? "" : `${p.label} sign-in isn't set up on this server yet`, 250 onclick: (e) => { if (!p.oauth_available) { e.preventDefault(); toast(`${p.label} sign-in isn't set up on this server yet.${p.token_paste ? " Paste a private-app token instead." : ""}`); } } }, 251 p.connected ? "Reconnect" : `Sign in with ${p.label}`));
252 if (p.connected && can("campaign.run")) actions.push(h("button", { class: "btn sm", onclick: async () => { 253 if (!(await confirmDialog(`Suppress your ${p.label} accounts?`, "Every company in your CRM with a website is added to the suppression list, so existing customers never appear in a lead book.", "Suppress"))) return; 254 try { const res = await api(`/api/crm/${p.provider}/pull`, { method: "POST", body: { mode: "suppressions", limit: 2000 } }); toast(`Suppressed ${res.suppressed} of ${res.fetched} CRM companies.`); } 255 catch (err) { toastError(err); } 256 } }, "Suppress my customers")); 257 if (p.connected && manage) actions.push(h("button", { class: "btn sm ghost danger", onclick: async () => { 258 if (!(await confirmDialog(`Disconnect ${p.label}?`, "Pushes and pulls stop; data already in the CRM stays.", "Disconnect", true))) return; 259 try { await api(`/api/crm/${p.provider}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); } 260 } }, "Disconnect")); 261 return h("div", { class: "provider" }, 262 h("div", { class: "row wrap" }, h("strong", {}, p.label), 263 p.connected ? h("span", { class: "chip mini teal" }, `connected · ${p.account || p.kind}`) : h("span", { class: "chip mini sentinel" }, "not connected"), 264 p.last_error ? h("span", { class: "chip mini danger", title: p.last_error }, "last call failed") : null, 265 p.last_used_at ? h("span", { class: "small muted" }, `used ${fmtDate(p.last_used_at, true)}`) : null),
266 h("div", { class: "row wrap" }, token ? h("div", { class: "spacer" }, token) : h("span", { class: "spacer" }), actions)); 267 }); 268 const hook = h("input", { class: "input", type: "password", placeholder: slack.connected ? `Connected (${slack.hint}) · paste a new webhook to replace` : "https://hooks.slack.com/services/â¦", autocomplete: "off" }); 269 mount(card, banner, 270 h("div", { class: "card-head" }, h("h3", {}, "CRM connections")), 271 h("p", { class: "muted small" }, "Pull your CRM accounts in as candidates or as a suppression list; push revealed accounts out as company + contact + an evidence note with source links. Tokens are stored encrypted, per workspace."), 272 h("div", { class: "stack" }, rows), 273 h("div", { class: "card-head" }, h("h3", {}, "Slack alerts for watchlists")), 274 h("p", { class: "muted small" }, "Watched accounts are re-checked nightly; new signals arrive as an email digest and, with a webhook here, in Slack. Turn Slack on per campaign in its Activity tab."), 275 manage ? h("div", { class: "row wrap" }, h("div", { class: "spacer" }, hook), 276 h("button", { class: "btn sm", onclick: async () => { 277 try { await api("/api/alerts/slack", { method: "PUT", body: { webhook_url: hook.value.trim() } }); toast("Slack webhook saved."); draw(); } catch (err) { toastError(err); } 278 } }, "Save webhook"), 279 slack.connected ? h("button", { class: "btn sm ghost danger", onclick: async () => { try { await api("/api/alerts/slack", { method: "DELETE" }); draw(); } catch (err) { toastError(err); } } }, "Remove") : null) 280 : h("p", { class: "small" }, slack.connected ? "Slack webhook configured." : "No Slack webhook yet (owners and admins can add one).")); 281 } 282 await draw(); 283 return card; 284} 285 286 287// --------------------------------------------------------------------------- 288// Your LinkedIn / X (through Unipile's hosted sign-in). Read-only; paid plans; daily budget shown. 289// --------------------------------------------------------------------------- 290const USAGE_LABEL = { search_page: "search pages", profile_view: "profile views", company_view: "company pages", posts_fetch: "post reads" }; 291 292async function socialCard() { 293 const card = h("div", { class: "card stack" }); 294 const manage = can("tenant.manage"); 295 async function draw() { 296 const data = await api("/api/social"); 297 const params = new URLSearchParams(location.search); 298 const banner = params.get("social") === "error" ? h("div", { class: "banner danger" }, "The sign-in didn't complete. Nothing was connected; try again.") 299 : params.get("social") === "connected" ? h("div", { class: "banner teal" }, "Connected. It can take a few seconds for the account to show as ready.") : null; 300 const gated = data.allowed <= 0; 301 const rows = data.providers.map((p) => { 302 const acc = data.accounts.find((a) => a.provider === p.provider && a.status !== "disconnected"); 303 const actions = []; 304 if (manage && (!acc || acc.status === "error" || acc.status === "checkpoint")) actions.push(h("button", { class: "btn sm", disabled: gated || !data.configured, onclick: async () => { 305 try { 306 const res = await api(`/api/social/${p.provider}/connect`, { method: "POST" }); 307 location.assign(res.url); 308 } catch (err) { toastError(err); } 309 } }, acc && acc.status === "checkpoint" ? "Reconnect" : `Connect ${p.label}`)); 310 if (acc && acc.status !== "pending" && manage) actions.push(h("button", { class: "btn sm ghost", onclick: async () => { 311 try { const r = await api(`/api/social/${p.provider}/refresh`, { method: "POST" }); toast(`Unipile says: ${r.unipile_status}.`); draw(); } catch (err) { toastError(err); } 312 } }, "Check status")); 313 if (acc && acc.paused && manage) actions.push(h("button", { class: "btn sm primary", onclick: async () => { 314 try { await api(`/api/social/${p.provider}/resume`, { method: "POST" }); toast("Sending resumed."); draw(); } catch (err) { toastError(err); } 315 } }, "Resume sending")); 316 if (acc && manage) actions.push(h("button", { class: "btn sm ghost danger", onclick: async () => { 317 if (!(await confirmDialog(`Disconnect ${p.label}?`, "We remove the account from Unipile immediately. Facts already stored stay in your workspace.", "Disconnect", true))) return; 318 try { await api(`/api/social/${p.provider}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); } 319 } }, "Disconnect")); 320 const SEND_LABEL = { invite: "invites", dm: "DMs", email: "emails", x_dm: "X DMs" }; 321 const usage = acc && acc.status === "connected" ? h("div", { class: "small muted row wrap" }, 322 Object.entries(acc.usage_today || {}).filter(([, v]) => v.cap).map(([k, v]) => h("span", { class: "chip mini", title: `Today's read budget for ${USAGE_LABEL[k]}` }, `${v.used}/${v.cap} ${USAGE_LABEL[k]}`)), 323 Object.entries(acc.sends_today || {}).map(([k, v]) => h("span", { class: "chip mini source", title: `Sends today / daily cap (weekly ${v.weekly_cap}); warm-up ${Math.round((acc.warmup.factor || 1) * 100)} %` }, `${v.used}/${v.cap} ${SEND_LABEL[k] || k} sent`)), 324 acc.warmup && acc.warmup.factor < 1 ? h("span", { class: "chip mini warn", title: "Sending c
324aps ramp up over the first two weeks" }, `warm-up ${Math.round(acc.warmup.factor * 100)} %`) : null) : null; 325 const toggle = acc && acc.status === "connected" && manage ? h("label", { class: "row small", style: "gap:6px" }, 326 h("input", { type: "checkbox", checked: acc.use_in_enrich, onchange: async (e) => { try { await api(`/api/social/${p.provider}`, { method: "PATCH", body: { use_in_enrich: e.target.checked } }); } catch (err) { toastError(err); } } }), 327 "Read during Enrich") : null; 328 return h("div", { class: "provider" }, 329 h("div", { class: "row wrap" }, h("strong", {}, p.label), 330 acc && acc.status === "connected" ? h("span", { class: "chip mini teal" }, `connected · ${acc.display_name || acc.account_hint}${p.provider === "linkedin" ? ` · ${acc.tier.replace("_", " ")}` : ""}`) 331 : acc && acc.status === "checkpoint" ? h("span", { class: "chip mini warn" }, "needs re-verification") 332 : acc && acc.status === "pending" ? h("span", { class: "chip mini" }, "sign-in pending") 333 : h("span", { class: "chip mini sentinel" }, "not connected"), 334 acc && acc.paused ? h("span", { class: "chip mini danger", title: acc.pause_reason || "" }, "sending paused") : null, 335 acc && acc.last_error && !acc.paused ? h("span", { class: "chip mini danger", title: acc.last_error }, "attention") : null, 336 h("span", { class: "spacer" }), actions), 337 usage, toggle); 338 }); 339 mount(card, banner, 340 h("div", { class: "card-head" }, h("h3", {}, "Your LinkedIn, mailbox and X"), h("span", { class: "spacer" }), 341 h("span", { class: "small muted" }, `${data.accounts.filter((a) => a.status === "connected").length}/${data.allowed} connected`)), 342 h("p", { class: "muted small" }, "Connect your own accounts. LinkedIn is read during Enrich (company page, job posts, recent posts) and Find people; LinkedIn, your mailbox and X send the outreach sequences you approve. You sign in on Unipile's page and we never see a password. Reads and sends stay inside daily and weekly limits with a two-week warm-up, and anything the platform flags pauses sending automatically."), 343 gated ? upgradePrompt("Connecting LinkedIn or X", { plan: data.plan }) : null, 344 !data.configured && !gated ? h("div", { class: "banner warn" }, "Connecting accounts isn't set up on this server yet (VT_UNIPILE_API_KEY).") : null, 345 h("div", { class: "stack" }, rows)); 346 } 347 await draw(); 348 return card; 349} 350
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.