| new file mode 100644 |
| index 0000000..2ede387 |
| --- /dev/null |
| +++ b/web/static/apps/hubspot.js |
| @@ -0,0 +1,232 @@ |
| +// The HubSpot companion app (docs/integrations.md § companion apps). Once |
| +// you've connected HubSpot in Integrations (OAuth or a private-app token), |
| +// this app installs: recent contacts and deals as a small dashboard in its |
| +// own thread. |
| +// |
| +// Same rail as the GitHub and Stripe apps: NO HubSpot sidecar. Every fetch |
| +// goes through ctx.host.connected.requestOp (a one-shot read grant sealed to |
| +// the instance's existing bot); results are bounded by the hubspot manifest's |
| +// `pick` projections, fold into state as typed events, and sync across |
| +// devices via snapshots. TAKEOVER app (no chatMessage): CRM data never echoes |
| +// into chat bubbles or notifications. |
| +import { defineApp } from "../apps.js"; |
| + |
| +// Results arrive pick-projected with flattened dot-path keys |
| +// ("properties.email") — normalise once at fold time, drop the malformed. |
| +const normContacts = (list) => |
| + (Array.isArray(list) ? list : []) |
| + .filter((c) => c && typeof c.id === "string") |
| + .map((c) => ({ |
| + id: c.id, |
| + email: typeof c["properties.email"] === "string" ? c["properties.email"] : "", |
| + first: typeof c["properties.firstname"] === "string" ? c["properties.firstname"] : "", |
| + last: typeof c["properties.lastname"] === "string" ? c["properties.lastname"] : "", |
| + created: typeof c.createdAt === "string" ? c.createdAt : "", |
| + })); |
| + |
| +const normDeals = (list) => |
| + (Array.isArray(list) ? list : []) |
| + .filter((d) => d && typeof d.id === "string") |
| + .map((d) => ({ |
| + id: d.id, |
| + name: typeof d["properties.dealname"] === "string" ? d["properties.dealname"] : "", |
| + amount: typeof d["properties.amount"] === "string" ? d["properties.amount"] : "", |
| + stage: typeof d["properties.dealstage"] === "string" ? d["properties.dealstage"] : "", |
| + close: typeof d["properties.closedate"] === "string" ? d["properties.closedate"] : "", |
| + created: typeof d.createdAt === "string" ? d.createdAt : "", |
| + })); |
| + |
| +// ISO stamp → short local date; "" when unparsable. |
| +function day(iso) { |
| + const t = Date.parse(iso || ""); |
| + if (!t) return ""; |
| + try { |
| + return new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" }); |
| + } catch { |
| + return ""; |
| + } |
| +} |
| + |
| +// HubSpot deal stages are internal slugs ("appointmentscheduled") — make them |
| +// readable without pretending to know the pipeline's real labels. |
| +const stageLabel = (s) => (s || "").replace(/[_-]+/g, " ").trim() || "—"; |
| + |
| +let busy = false; |
| +let autoLoading = false; |
| + |
| +export const app = defineApp({ |
| + name: "hubspot", |
| + title: "HubSpot", |
| + blurb: "Contacts & deals", |
| + glyph: "🧲", |
| + scope: "personal", |
| + // Only listed in New chat once HubSpot is linked (shell.js gates on this). |
| + requiresService: "hubspot", |
| + initialState: () => ({ contacts: [], deals: [], fetchedAt: 0 }), |
| + |
| + events: { |
| + "hubspot.contacts": (s, e) => (Array.isArray(e.contacts) ? { ...s, contacts: normContacts(e.contacts), fetchedAt: e.ts || s.fetchedAt } : s), |
| + "hubspot.deals": (s, e) => (Array.isArray(e.deals) ? { ...s, deals: normDeals(e.deals), fetchedAt: e.ts || s.fetchedAt } : s), |
| + }, |
| + |
| + styles: ` |
| +.hs-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } |
| +.hs-title { margin: 0; font-size: 18px; } |
| +.hs-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; } |
| +.hs-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); } |
| +.hs-btn:disabled { opacity: .6; cursor: default; } |
| +.hs-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); } |
| +.hs-scroll { overflow-x: auto; margin: 0 -4px; } |
| +.hs-table { width: 100%; border-collapse: collapse; font-size: 14px; } |
| +.hs-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } |
| +.hs-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } |
| +.hs-name { font-weight: 500; overflow-wrap: anywhere; } |
| +.hs-muted { color: var(--muted); overflow-wrap: anywhere; } |
| +.hs-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; } |
| +.hs-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } |
| +.hs-empty { color: var(--muted); padding: 8px 0; } |
| +.hs-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } |
| +`, |
| + |
| + render(state, el, ctx) { |
| + const host = ctx.host && ctx.host.connected; |
| + state = state || { contacts: [], deals: [], fetchedAt: 0 }; |
| + |
| + const head = document.createElement("div"); |
| + head.className = "hs-head"; |
| + el.appendChild(head); |
| + |
| + const title = document.createElement("h2"); |
| + title.className = "hs-title"; |
| + title.textContent = "HubSpot"; |
| + const sub = document.createElement("span"); |
| + sub.className = "hs-sub"; |
| + |
| + const note = document.createElement("div"); |
| + note.className = "hs-note"; |
| + |
| + if (!host || !host.requestOp) { |
| + head.appendChild(title); |
| + const empty = document.createElement("div"); |
| + empty.className = "hs-empty"; |
| + empty.textContent = "HubSpot needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; |
| + el.append(empty); |
| + return; |
| + } |
| + |
| + // One Refresh pulls both reads; each folds as its own event so a failing |
| + // one never blanks the other. Whole body under try/finally so a throw can |
| + // never wedge the module-level busy flag. |
| + const refresh = async () => { |
| + if (busy) return; |
| + busy = true; |
| + const errors = []; |
| + try { |
| + note.textContent = ""; |
| + paint(); |
| + const pulls = [ |
| + ["contacts.list", (d) => ctx.emit({ type: "hubspot.contacts", contacts: d })], |
| + ["deals.list", (d) => ctx.emit({ type: "hubspot.deals", deals: d })], |
| + ]; |
| + await Promise.all( |
| + pulls.map(async ([op, emit]) => { |
| + let res; |
| + try { |
| + res = await host.requestOp("hubspot", op, {}); |
| + } catch (e) { |
| + res = { ok: false, error: (e && e.message) || String(e) }; |
| + } |
| + if (res && res.ok && typeof res.data === "string") { |
| + try { |
| + emit(JSON.parse(res.data)); |
| + return; |
| + } catch {} |
| + } |
| + errors.push(`${op}: ${(res && res.error) || "no response"}`); |
| + }), |
| + ); |
| + } finally { |
| + busy = false; |
| + autoLoading = false; |
| + } |
| + if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`; |
| + paint(); |
| + }; |
| + |
| + const refreshBtn = document.createElement("button"); |
| + refreshBtn.type = "button"; |
| + refreshBtn.className = "hs-btn"; |
| + refreshBtn.textContent = "↻ Refresh"; |
| + refreshBtn.onclick = refresh; |
| + head.append(title, sub, refreshBtn); |
| + |
| + const section = (heading, rows, cols, rowCells) => { |
| + const h = document.createElement("div"); |
| + h.className = "hs-h"; |
| + h.textContent = heading; |
| + el.appendChild(h); |
| + if (!rows.length) { |
| + const empty = document.createElement("div"); |
| + empty.className = "hs-empty"; |
| + empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh."; |
| + el.appendChild(empty); |
| + return; |
| + } |
| + const scroll = document.createElement("div"); |
| + scroll.className = "hs-scroll"; |
| + const table = document.createElement("table"); |
| + table.className = "hs-table"; |
| + const thead = document.createElement("thead"); |
| + const hr = document.createElement("tr"); |
| + for (const c of cols) { |
| + const th = document.createElement("th"); |
| + th.textContent = c; |
| + hr.appendChild(th); |
| + } |
| + thead.appendChild(hr); |
| + table.appendChild(thead); |
| + const tbody = document.createElement("tbody"); |
| + for (const r of rows) { |
| + const tr = document.createElement("tr"); |
| + for (const cell of rowCells(r)) tr.appendChild(cell); |
| + tbody.appendChild(tr); |
| + } |
| + table.appendChild(tbody); |
| + scroll.appendChild(table); |
| + el.appendChild(scroll); |
| + }; |
| + |
| + const td = (text, cls) => { |
| + const c = document.createElement("td"); |
| + if (cls) c.className = cls; |
| + c.textContent = text; |
| + return c; |
| + }; |
| + |
| + section("Recent contacts", state.contacts, ["Name", "Email", "Added"], (c) => [ |
| + td([c.first, c.last].filter(Boolean).join(" ") || "—", "hs-name"), |
| + td(c.email || "—", "hs-muted"), |
| + td(day(c.created), "hs-when"), |
| + ]); |
| + section("Recent deals", state.deals, ["Deal", "Amount", "Stage", "Closes"], (d) => [ |
| + td(d.name || "—", "hs-name"), |
| + td(d.amount ? d.amount : "—", "hs-amount"), |
| + td(stageLabel(d.stage), "hs-muted"), |
| + td(day(d.close), "hs-when"), |
| + ]); |
| + |
| + el.appendChild(note); |
| + |
| + const paint = () => { |
| + sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""; |
| + refreshBtn.disabled = busy; |
| + }; |
| + paint(); |
| + |
| + // First open pulls once, automatically — after the surface is fully built. |
| + if (!state.fetchedAt && !busy && !autoLoading) { |
| + autoLoading = true; |
| + refresh(); |
| + } |
| + }, |
| +}); |