@graph8/sdk 0.362.0 → 0.370.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.
Files changed (349) hide show
  1. package/README.md +14 -10
  2. package/blocks/_foundation/README.md +64 -1
  3. package/blocks/_foundation/block.json +79 -6
  4. package/blocks/_foundation/components/api-error.tsx +9 -7
  5. package/blocks/_foundation/components/brand-provider.tsx +23 -0
  6. package/blocks/_foundation/components/confirmed-action.tsx +90 -0
  7. package/blocks/_foundation/components/connect-graph8.tsx +280 -0
  8. package/blocks/_foundation/components/screen/index.ts +1 -0
  9. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  10. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  11. package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
  12. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  13. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  14. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  15. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  16. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  17. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  18. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  19. package/blocks/_foundation/lib/g8-client.ts +1 -1
  20. package/blocks/_foundation/lib/plural.ts +23 -0
  21. package/blocks/_foundation/server/connect-route.ts +15 -0
  22. package/blocks/_foundation/server/connect.ts +127 -0
  23. package/blocks/_foundation/server/local-key.ts +70 -0
  24. package/blocks/_foundation/server/proxy.ts +103 -3
  25. package/blocks/_foundation/server/route.ts +16 -4
  26. package/blocks/agents/README.md +14 -0
  27. package/blocks/agents/block.json +33 -1
  28. package/blocks/agents/components/agent-builder.tsx +13 -8
  29. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
  30. package/blocks/agents/components/agent-run-detail.tsx +102 -0
  31. package/blocks/agents/components/agent-runs.tsx +16 -7
  32. package/blocks/agents/components/agents-screen.tsx +184 -0
  33. package/blocks/analytics/README.md +13 -0
  34. package/blocks/analytics/block.json +45 -1
  35. package/blocks/analytics/components/analytics-screen.tsx +209 -0
  36. package/blocks/analytics/components/leaderboard.tsx +14 -7
  37. package/blocks/analytics/components/report-builder.tsx +23 -9
  38. package/blocks/analytics/components/report-detail.tsx +84 -0
  39. package/blocks/analytics/components/trend-chart.tsx +62 -0
  40. package/blocks/appointments/block.json +6 -2
  41. package/blocks/appointments/components/availability-editor.tsx +17 -5
  42. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  43. package/blocks/appointments/components/event-type-related.tsx +4 -2
  44. package/blocks/appointments/components/meeting-list.tsx +9 -5
  45. package/blocks/appointments/components/meetings-screen.tsx +7 -4
  46. package/blocks/appointments/components/scheduling-screen.tsx +2 -1
  47. package/blocks/block.schema.json +18 -0
  48. package/blocks/block.types.ts +17 -0
  49. package/blocks/campaigns/README.md +41 -11
  50. package/blocks/campaigns/block.json +105 -42
  51. package/blocks/campaigns/components/campaign-assets.tsx +16 -5
  52. package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
  53. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  54. package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
  55. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  56. package/blocks/campaigns/components/icp-detail.tsx +154 -0
  57. package/blocks/campaigns/components/icp-editor.tsx +15 -15
  58. package/blocks/campaigns/components/persona-detail.tsx +172 -0
  59. package/blocks/campaigns/components/persona-editor.tsx +14 -11
  60. package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
  61. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  62. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  63. package/blocks/coach/README.md +10 -0
  64. package/blocks/coach/block.json +16 -1
  65. package/blocks/coach/components/coach-screen.tsx +222 -0
  66. package/blocks/copilot/README.md +13 -0
  67. package/blocks/copilot/block.json +14 -1
  68. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  69. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  70. package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
  71. package/blocks/crm/README.md +28 -0
  72. package/blocks/crm/block.json +58 -2
  73. package/blocks/crm/components/companies-screen.tsx +345 -0
  74. package/blocks/crm/components/company-detail.tsx +8 -6
  75. package/blocks/crm/components/company-table.tsx +11 -4
  76. package/blocks/crm/components/contact-detail.tsx +6 -3
  77. package/blocks/crm/components/contact-table.tsx +10 -3
  78. package/blocks/crm/components/contacts-screen.tsx +359 -0
  79. package/blocks/crm/components/dedupe-review.tsx +7 -5
  80. package/blocks/crm/components/import-wizard.tsx +4 -2
  81. package/blocks/crm/components/notes-panel.tsx +4 -4
  82. package/blocks/crm/components/record-form.tsx +5 -3
  83. package/blocks/crm/components/task-list.tsx +4 -4
  84. package/blocks/crm/components/tasks-screen.tsx +379 -0
  85. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  86. package/blocks/crm/hooks/use-page-query.ts +7 -1
  87. package/blocks/crm/lib/crm-types.ts +12 -2
  88. package/blocks/crm/lib/screen-parts.tsx +105 -0
  89. package/blocks/deals/block.json +9 -3
  90. package/blocks/deals/components/deal-detail.tsx +23 -11
  91. package/blocks/deals/components/deal-related.tsx +1 -1
  92. package/blocks/deals/components/deals-screen.tsx +10 -5
  93. package/blocks/deals/components/stage-editor.tsx +4 -4
  94. package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
  95. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  96. package/blocks/dialer/README.md +34 -2
  97. package/blocks/dialer/block.json +67 -1
  98. package/blocks/dialer/components/call-contact-button.tsx +252 -0
  99. package/blocks/dialer/components/call-log-screen.tsx +205 -0
  100. package/blocks/dialer/components/call-log.tsx +7 -3
  101. package/blocks/dialer/components/call-queue.tsx +1 -1
  102. package/blocks/dialer/components/dialer-screen.tsx +319 -0
  103. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  104. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  105. package/blocks/dialer/components/session-start-form.tsx +4 -2
  106. package/blocks/dialer/components/softphone.tsx +3 -1
  107. package/blocks/enrichment/README.md +14 -2
  108. package/blocks/enrichment/block.json +32 -4
  109. package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
  110. package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
  111. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  112. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  113. package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
  114. package/blocks/enrichment/components/waterfall-config.tsx +39 -10
  115. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  116. package/blocks/forms/block.json +32 -18
  117. package/blocks/forms/components/form-builder.tsx +3 -1
  118. package/blocks/forms/components/form-embed.tsx +3 -1
  119. package/blocks/forms/components/forms-screen.tsx +5 -2
  120. package/blocks/forms/components/submissions-table.tsx +3 -1
  121. package/blocks/inbox/README.md +27 -3
  122. package/blocks/inbox/block.json +14 -2
  123. package/blocks/inbox/components/inbox-list.tsx +7 -3
  124. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  125. package/blocks/inbox/components/reply-composer.tsx +65 -9
  126. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  127. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  128. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  129. package/blocks/inbox/lib/inbox-types.ts +47 -1
  130. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  131. package/blocks/integrations/README.md +9 -0
  132. package/blocks/integrations/block.json +46 -1
  133. package/blocks/integrations/components/crm-detail.tsx +100 -0
  134. package/blocks/integrations/components/integration-list.tsx +9 -4
  135. package/blocks/integrations/components/integrations-screen.tsx +135 -0
  136. package/blocks/integrations/components/sync-status.tsx +5 -3
  137. package/blocks/integrations/components/webhook-detail.tsx +160 -0
  138. package/blocks/integrations/components/webhook-manager.tsx +13 -8
  139. package/blocks/integrations/lib/integrations-utils.ts +6 -1
  140. package/blocks/intent/README.md +14 -0
  141. package/blocks/intent/block.json +27 -1
  142. package/blocks/intent/components/intent-feed.tsx +5 -2
  143. package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
  144. package/blocks/intent/components/intent-screen.tsx +265 -0
  145. package/blocks/intent/components/keyword-detail.tsx +124 -0
  146. package/blocks/lifecycle/block.json +13 -8
  147. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  148. package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
  149. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  150. package/blocks/lists/README.md +13 -0
  151. package/blocks/lists/block.json +39 -1
  152. package/blocks/lists/components/add-to-list-button.tsx +3 -2
  153. package/blocks/lists/components/list-picker.tsx +5 -1
  154. package/blocks/lists/components/list-table.tsx +11 -5
  155. package/blocks/lists/components/lists-screen.tsx +344 -0
  156. package/blocks/lists/components/suppression-manager.tsx +6 -2
  157. package/blocks/lists/components/suppressions-screen.tsx +255 -0
  158. package/blocks/mailboxes/README.md +17 -0
  159. package/blocks/mailboxes/block.json +65 -17
  160. package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
  161. package/blocks/mailboxes/components/domain-detail.tsx +142 -0
  162. package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
  163. package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
  164. package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
  165. package/blocks/mailboxes/components/warmup-health.tsx +4 -2
  166. package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
  167. package/blocks/newsletter/block.json +40 -24
  168. package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
  169. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  170. package/blocks/nurture/block.json +28 -14
  171. package/blocks/nurture/components/nurture-builder.tsx +3 -1
  172. package/blocks/nurture/components/nurture-enroll.tsx +6 -3
  173. package/blocks/nurture/components/nurture-form.tsx +23 -3
  174. package/blocks/nurture/components/nurture-screen.tsx +10 -6
  175. package/blocks/nurture/lib/nurture-types.ts +1 -1
  176. package/blocks/objects/README.md +17 -0
  177. package/blocks/objects/block.json +29 -16
  178. package/blocks/objects/components/object-not-found.tsx +3 -1
  179. package/blocks/objects/components/object-record-detail.tsx +2 -1
  180. package/blocks/objects/components/object-schema-editor.tsx +3 -1
  181. package/blocks/objects/components/object-table.tsx +13 -3
  182. package/blocks/objects/components/objects-screen.tsx +147 -0
  183. package/blocks/objects/lib/object-types.ts +1 -1
  184. package/blocks/pages/block.json +28 -23
  185. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  186. package/blocks/pages/components/content-library.tsx +9 -4
  187. package/blocks/pages/components/page-list.tsx +2 -1
  188. package/blocks/quotes/block.json +17 -12
  189. package/blocks/quotes/components/product-picker.tsx +3 -1
  190. package/blocks/quotes/components/quote-related.tsx +5 -2
  191. package/blocks/radar/README.md +10 -0
  192. package/blocks/radar/block.json +41 -1
  193. package/blocks/radar/components/competitor-detail.tsx +117 -0
  194. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  195. package/blocks/radar/components/radar-report.tsx +4 -2
  196. package/blocks/radar/components/radar-screen.tsx +214 -0
  197. package/blocks/radar/components/report-detail.tsx +104 -0
  198. package/blocks/search/README.md +10 -0
  199. package/blocks/search/block.json +16 -1
  200. package/blocks/search/components/filter-builder.tsx +3 -3
  201. package/blocks/search/components/prospect-results.tsx +5 -3
  202. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  203. package/blocks/search/components/saved-searches.tsx +4 -1
  204. package/blocks/search/hooks/use-filter-options.ts +20 -6
  205. package/blocks/search/lib/search-types.ts +2 -0
  206. package/blocks/sequencer/README.md +49 -9
  207. package/blocks/sequencer/block.json +99 -19
  208. package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
  209. package/blocks/sequencer/components/sequence-builder.tsx +211 -46
  210. package/blocks/sequencer/components/sequence-detail.tsx +259 -0
  211. package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
  212. package/blocks/sequencer/components/sequence-list.tsx +33 -8
  213. package/blocks/sequencer/components/sequence-stats.tsx +3 -2
  214. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  215. package/blocks/sequencer/components/sequences-screen.tsx +159 -0
  216. package/blocks/sequencer/components/step-editor.tsx +3 -1
  217. package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
  218. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  219. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  220. package/blocks/social-ads/README.md +11 -0
  221. package/blocks/social-ads/block.json +101 -2
  222. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  223. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  224. package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
  225. package/blocks/social-ads/components/audience-sync.tsx +11 -7
  226. package/blocks/social-ads/components/campaign-detail.tsx +133 -0
  227. package/blocks/social-ads/components/creative-detail.tsx +150 -0
  228. package/blocks/social-ads/components/creative-gallery.tsx +15 -5
  229. package/blocks/social-ads/components/post-composer.tsx +3 -1
  230. package/blocks/social-ads/components/post-detail.tsx +160 -0
  231. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  232. package/blocks/social-ads/components/social-screen.tsx +160 -0
  233. package/blocks/visitors/README.md +10 -0
  234. package/blocks/visitors/block.json +25 -1
  235. package/blocks/visitors/components/snippet-install.tsx +3 -1
  236. package/blocks/visitors/components/visitor-company-card.tsx +2 -2
  237. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  238. package/blocks/visitors/components/visitor-feed.tsx +11 -2
  239. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  240. package/blocks/webchat/README.md +10 -0
  241. package/blocks/webchat/block.json +21 -7
  242. package/blocks/webchat/components/chat-widget-config.tsx +7 -4
  243. package/blocks/webchat/components/conversation-list.tsx +7 -3
  244. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  245. package/blocks/workbench/README.md +11 -0
  246. package/blocks/workbench/block.json +48 -1
  247. package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
  248. package/blocks/workbench/components/workbench-grid.tsx +9 -3
  249. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  250. package/blocks/workbench/components/workbench-screen.tsx +192 -0
  251. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  252. package/blocks/workflows/README.md +13 -0
  253. package/blocks/workflows/block.json +28 -9
  254. package/blocks/workflows/components/run-history.tsx +31 -9
  255. package/blocks/workflows/components/trigger-config.tsx +3 -1
  256. package/blocks/workflows/components/workflow-editor.tsx +3 -1
  257. package/blocks/workflows/components/workflow-list.tsx +13 -6
  258. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  259. package/dist/cli.js +2709 -1721
  260. package/dist/errors.d.mts +34 -0
  261. package/dist/errors.d.ts +34 -0
  262. package/dist/errors.js +46 -0
  263. package/dist/errors.js.map +1 -0
  264. package/dist/errors.mjs +21 -0
  265. package/dist/errors.mjs.map +1 -0
  266. package/dist/index.browser.js +11 -5
  267. package/dist/index.browser.js.map +1 -1
  268. package/dist/index.browser.mjs +11 -5
  269. package/dist/index.browser.mjs.map +1 -1
  270. package/dist/index.d.mts +164 -25
  271. package/dist/index.d.ts +164 -25
  272. package/dist/index.js +11 -5
  273. package/dist/index.js.map +1 -1
  274. package/dist/index.mjs +11 -5
  275. package/dist/index.mjs.map +1 -1
  276. package/dist/react.browser.js +10 -4
  277. package/dist/react.browser.js.map +1 -1
  278. package/dist/react.browser.mjs +10 -4
  279. package/dist/react.browser.mjs.map +1 -1
  280. package/dist/react.d.mts +142 -5
  281. package/dist/react.d.ts +142 -5
  282. package/dist/react.js +10 -4
  283. package/dist/react.js.map +1 -1
  284. package/dist/react.mjs +10 -4
  285. package/dist/react.mjs.map +1 -1
  286. package/dist/types/blocks/add.d.ts +11 -4
  287. package/dist/types/blocks/brand.d.ts +113 -0
  288. package/dist/types/blocks/compose.d.ts +53 -0
  289. package/dist/types/blocks/create.d.ts +22 -19
  290. package/dist/types/blocks/doctor.d.ts +5 -1
  291. package/dist/types/blocks/navfile.d.ts +35 -0
  292. package/dist/types/blocks/paths.d.ts +4 -0
  293. package/dist/types/blocks/prefixes.d.ts +37 -0
  294. package/dist/types/blocks/registry.d.ts +17 -0
  295. package/dist/types/blocks/screens.d.ts +53 -0
  296. package/dist/types/cli.d.ts +2 -0
  297. package/dist/types/errors.d.ts +33 -0
  298. package/dist/types/http.d.ts +2 -18
  299. package/dist/types/version.d.ts +2 -2
  300. package/docs/agents.md +15 -4
  301. package/docs/api/README.md +2 -2
  302. package/docs/api/by-path.md +1 -0
  303. package/docs/api/desk.d.ts +98 -2
  304. package/docs/api/desk.md +123 -1
  305. package/docs/api/inbox.d.ts +10 -2
  306. package/docs/api/inbox.md +10 -2
  307. package/docs/api/seo.d.ts +19 -1
  308. package/docs/api/seo.md +24 -1
  309. package/package.json +16 -2
  310. package/stacks/6sense.json +41 -16
  311. package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
  312. package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
  313. package/stacks/apollo.json +118 -36
  314. package/stacks/calendly.json +60 -0
  315. package/stacks/clay/pages/companies.tsx.tmpl +15 -0
  316. package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
  317. package/stacks/clay.json +59 -26
  318. package/stacks/gong.json +81 -35
  319. package/stacks/hootsuite.json +99 -0
  320. package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
  321. package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
  322. package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
  323. package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
  324. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  325. package/stacks/hubspot.json +167 -66
  326. package/stacks/intercom.json +48 -20
  327. package/stacks/mailchimp.json +52 -33
  328. package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
  329. package/stacks/outreach.json +88 -25
  330. package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
  331. package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
  332. package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
  333. package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
  334. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  335. package/stacks/pipedrive.json +115 -0
  336. package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
  337. package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
  338. package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
  339. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  340. package/stacks/salesforce.json +81 -31
  341. package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
  342. package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
  343. package/stacks/zoominfo.json +102 -0
  344. package/styles/styles.css +1 -1
  345. package/templates/next-app/README.md.tmpl +40 -9
  346. package/templates/next-app/app/layout.tsx.tmpl +7 -2
  347. package/templates/next-app/components/app-shell.tsx.tmpl +99 -89
  348. package/templates/next-app/dot-gitignore.tmpl +2 -0
  349. package/templates/next-app/package.json.tmpl +1 -1
@@ -9,7 +9,13 @@
9
9
  * Returns { content (HTML), plain_content }; it delivers NOTHING.
10
10
  * 2. POST /inbox/{reply_id}/send (inbox.sendReply) EXTERNAL -> ConfirmSpendDialog first
11
11
  * Reaches a real person and cannot be recalled. A fixed Idempotency-Key per compose makes a double
12
- * click or a retry safe.
12
+ * click or a retry safe. Sends `from_address` = the thread's own sender (email mailbox, SMS number,
13
+ * LinkedIn account id) and `to` = the thread's contact, so the reply leaves from the mailbox the
14
+ * conversation is on. Before this the composer sent neither sender nor (for SMS) number, and the API
15
+ * refused every email reply with 400 "'from_address' (mailbox) is required".
16
+ * Only when an SMS or LinkedIn thread names no sender, one read fills a sender picker:
17
+ * GET /sms/phone-numbers (channels.listSmsPhoneNumbers) or
18
+ * GET /inbox/linkedin/accounts (aiInbox.listInboxLinkedinAccounts) READ
13
19
  *
14
20
  * Both calls are gated by tier: nothing is billed or sent until the person clicks the dialog's button.
15
21
  */
@@ -18,9 +24,10 @@ import { useIdempotencyKey } from "../lib/idempotency";
18
24
  import { Button, Input, Textarea } from "@graph8/sdk/ui";
19
25
  import { ApiError } from "@/g8/components/api-error";
20
26
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
- import { g8Post } from "@/g8/lib/g8-client";
27
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
22
28
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
- import { replyRecipient, stripHtml, type InboxThread } from "../lib/inbox-types";
29
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
30
+ import { replyRecipient, replySender, senderOptions, stripHtml, type InboxThread } from "../lib/inbox-types";
24
31
 
25
32
  type DraftOperation = "generate_from_scratch" | "shorten" | "expand" | "fix_grammar_spelling";
26
33
  type Pending = { kind: "draft"; operation: DraftOperation } | { kind: "send" } | null;
@@ -51,10 +58,22 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
51
58
  const [body, setBody] = useState("");
52
59
  const [subject, setSubject] = useState(thread.subject ? (/^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`) : "");
53
60
  const [to, setTo] = useState(replyRecipient(thread));
61
+ const threadSender = replySender(thread);
62
+ const [from, setFrom] = useState(threadSender);
54
63
  const [tone, setTone] = useState<(typeof TONES)[number]>("professional");
55
64
  const [objective, setObjective] = useState<(typeof OBJECTIVES)[number]>("follow_up");
56
65
  const [pending, setPending] = useState<Pending>(null);
57
- const idempotency = useIdempotencyKey(JSON.stringify([thread.channel, thread.id, body, to, channel === "email" ? subject : null]));
66
+ const idempotency = useIdempotencyKey(JSON.stringify([thread.channel, thread.id, body, to, from, channel === "email" ? subject : null]));
67
+ // SMS and LinkedIn cannot send without a sender; when the thread does not name one, offer the org's.
68
+ const needsSenderPick = !threadSender && (channel === "sms" || channel === "linkedin");
69
+ const senders = useG8Query(
70
+ async (signal) => {
71
+ const path = channel === "sms" ? "/sms/phone-numbers" : "/inbox/linkedin/accounts";
72
+ return senderOptions(channel, (await g8Get<unknown>(path, undefined, { signal })).data);
73
+ },
74
+ [channel, needsSenderPick],
75
+ { enabled: needsSenderPick },
76
+ );
58
77
  const threadKey = `${thread.channel}:${thread.id}`;
59
78
  const currentThread = useRef(threadKey);
60
79
  currentThread.current = threadKey;
@@ -64,6 +83,7 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
64
83
  setBody("");
65
84
  setSubject(thread.subject ? (/^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`) : "");
66
85
  setTo(replyRecipient(thread));
86
+ setFrom(replySender(thread));
67
87
  setPending(null);
68
88
  draft.reset();
69
89
  send.reset();
@@ -85,11 +105,18 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
85
105
  const send = useG8Mutation(async () => {
86
106
  const env = await g8Post<{ message_id?: string | null; status: string }>(
87
107
  `/inbox/${encodeURIComponent(thread.id)}/send`,
88
- { body, channel, subject: channel === "email" ? subject : undefined, to: to || undefined },
108
+ {
109
+ body,
110
+ channel,
111
+ subject: channel === "email" ? subject : undefined,
112
+ to: to || undefined,
113
+ // Empty only for an email thread the list did not label; the API then uses the thread's mailbox.
114
+ from_address: from || undefined,
115
+ },
89
116
  { idempotencyKey: idempotency.get() },
90
117
  );
91
118
  return env.data;
92
- });
119
+ }, { invalidates: ["inbox"] });
93
120
 
94
121
  const confirm = async () => {
95
122
  const current = pending;
@@ -112,7 +139,8 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
112
139
  };
113
140
 
114
141
  const busy = draft.loading || send.loading;
115
- const canSend = body.trim().length > 0 && (channel === "linkedin" || to.trim().length > 0) && !busy;
142
+ const hasSender = channel === "email" || from.trim().length > 0;
143
+ const canSend = body.trim().length > 0 && (channel === "linkedin" || to.trim().length > 0) && hasSender && !busy;
116
144
  const canTransform = body.trim().length > 0 && !busy;
117
145
  const canDraft = Boolean(draftChannel(channel));
118
146
 
@@ -121,7 +149,33 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
121
149
  {channel === "email" ? (
122
150
  <Input aria-label="Subject" placeholder="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} />
123
151
  ) : null}
124
- <Input aria-label="To" placeholder={channel === "sms" ? "Phone number" : "Recipient"} value={to} onChange={(e) => setTo(e.target.value)} />
152
+ {needsSenderPick ? (
153
+ <div className="space-y-1 text-sm">
154
+ <select
155
+ aria-label="Send from"
156
+ className="h-9 w-full rounded-md border bg-background px-2"
157
+ value={from}
158
+ disabled={senders.loading}
159
+ onChange={(e) => setFrom(e.target.value)}
160
+ >
161
+ <option value="">{senders.loading ? "Loading senders..." : channel === "sms" ? "Choose the number to text from" : "Choose the LinkedIn account to send from"}</option>
162
+ {(senders.data ?? []).map((o) => (
163
+ <option key={o.value} value={o.value}>
164
+ {o.label}
165
+ </option>
166
+ ))}
167
+ </select>
168
+ {senders.error ? <ApiError error={senders.error} title="Could not load your senders" onRetry={() => void senders.refetch()} /> : null}
169
+ {!senders.loading && !senders.error && senders.data?.length === 0 ? (
170
+ <p className="text-muted-foreground">{channel === "sms" ? "No SMS numbers on this account yet." : "No LinkedIn accounts connected yet."}</p>
171
+ ) : null}
172
+ </div>
173
+ ) : from ? (
174
+ <p className="text-xs text-muted-foreground">From {channel === "linkedin" ? "your LinkedIn account" : from}</p>
175
+ ) : null}
176
+ {channel !== "linkedin" ? (
177
+ <Input aria-label="To" placeholder={channel === "sms" ? "Phone number" : "Recipient"} value={to} onChange={(e) => setTo(e.target.value)} />
178
+ ) : null}
125
179
  <Textarea aria-label="Reply" rows={6} placeholder="Write your reply" value={body} onChange={(e) => setBody(e.target.value)} />
126
180
 
127
181
  {canDraft ? (
@@ -172,7 +226,9 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
172
226
  open={pending?.kind === "send"}
173
227
  tier="external"
174
228
  title={`Send this ${channel} reply?`}
175
- description={`It goes to ${to || "the contact"} from your connected ${channel === "email" ? "mailbox" : channel} account.`}
229
+ description={`It goes to ${to || "the contact"} from ${
230
+ channel === "email" ? (from || "the thread's mailbox") : channel === "sms" ? from : "your LinkedIn account"
231
+ }.`}
176
232
  recipients={1}
177
233
  confirming={send.loading}
178
234
  onConfirm={() => void confirm()}
@@ -23,6 +23,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
23
  import { useG8Query } from "@/g8/hooks/use-g8-query";
24
24
  import type { InboxTag, InboxThread } from "../lib/inbox-types";
25
25
  import { ThreadBadge } from "./thread-badge";
26
+ import { pluralWord } from "@/g8/lib/plural";
26
27
 
27
28
  type Pending = { kind: "tag"; ids: string[] } | { kind: "assign"; email: string } | null;
28
29
  interface TeamUser {
@@ -70,9 +71,10 @@ export function TagAndAssign({ thread, availableTags = [], onChanged, className
70
71
  const tag = useG8Mutation(async (ids: string[]) => {
71
72
  // Only the NEW ids: association can fire tag automations, so already-applied tags are not re-sent.
72
73
  return g8Post(`/inbox/${encodeURIComponent(thread.id)}/tag`, { tag_ids: ids.filter((id) => !applied.has(id)) }, { query: channelQuery });
73
- });
74
+ }, { invalidates: ["inbox"] });
74
75
  const assign = useG8Mutation(async (email: string) =>
75
76
  g8Post(`/inbox/${encodeURIComponent(thread.id)}/assign`, { assignee_email: email }, { query: channelQuery }),
77
+ { invalidates: ["inbox"] },
76
78
  );
77
79
 
78
80
  const addTag = async () => {
@@ -132,7 +134,7 @@ export function TagAndAssign({ thread, availableTags = [], onChanged, className
132
134
  </Button>
133
135
  </div>
134
136
  <Button size="sm" disabled={selected.length === 0 || tag.loading} onClick={() => setPending({ kind: "tag", ids: selected })}>
135
- Apply {selected.length > 0 ? `${selected.length} ` : ""}tag{selected.length === 1 ? "" : "s"}
137
+ Apply {selected.length > 0 ? `${selected.length} ` : ""}{pluralWord(selected.length, "tag")}
136
138
  </Button>
137
139
  {createTag.error ? <ApiError error={createTag.error} title="Could not create the tag" /> : null}
138
140
  {tag.error ? <ApiError error={tag.error} title="Could not apply the tags" /> : null}
@@ -19,6 +19,6 @@ export function useInboxThread(threadId: string | null | undefined, channel: str
19
19
  return env.data;
20
20
  },
21
21
  [threadId, channel],
22
- { enabled: Boolean(threadId) },
22
+ { enabled: Boolean(threadId), key: ["inbox", "detail", threadId ?? null, channel ?? null] },
23
23
  );
24
24
  }
@@ -35,6 +35,7 @@ export function useInboxThreads(filters: InboxFilters, page = 1, pageSize = 25)
35
35
  return { threads: env.data ?? [], hasNext: Boolean(env.pagination?.has_next), total: (env.pagination as { total?: number } | undefined)?.total };
36
36
  },
37
37
  [filters.channel, filters.status, filters.tag, filters.sequenceId, page, pageSize],
38
+ { key: ["inbox", "list", { channel: filters.channel, status: filters.status, tag: filters.tag, sequenceId: filters.sequenceId, page, pageSize }] },
38
39
  );
39
40
  return { ...query, threads: query.data?.threads ?? [], hasNext: query.data?.hasNext ?? false, total: query.data?.total };
40
41
  }
@@ -46,6 +46,14 @@ export interface InboxThread {
46
46
  assignees: InboxAssignee[];
47
47
  created_at?: string | null;
48
48
  updated_at?: string | null;
49
+ /** Email: the connected mailbox the thread belongs to; a reply is sent from it. */
50
+ mailbox?: string | null;
51
+ /** SMS: your number the thread runs on; a reply is sent from it. */
52
+ from_number?: string | null;
53
+ /** SMS: the contact's number; a reply goes to it. */
54
+ to_number?: string | null;
55
+ /** LinkedIn: the account the conversation belongs to; a reply is sent from it. */
56
+ linkedin_account_id?: number | null;
49
57
  }
50
58
 
51
59
  /** Statuses `GET /inbox?status=` accepts (case-insensitive). Anything else is a 422. */
@@ -95,6 +103,44 @@ export function formatWhen(iso: string | null | undefined): string {
95
103
  /** Default recipient for a reply: the contact's address on the thread's channel. */
96
104
  export function replyRecipient(thread: InboxThread): string {
97
105
  const c = thread.contact;
98
- if (thread.channel === "sms") return c?.phone ?? "";
106
+ if (thread.channel === "sms") return thread.to_number || c?.phone || "";
99
107
  return c?.email ?? "";
100
108
  }
109
+
110
+ /**
111
+ * Default sender for a reply, as `POST /inbox/{id}/send` expects it in `from_address`: the thread's mailbox
112
+ * (email), your number on the thread (SMS) or the numeric LinkedIn account id. Empty when the thread does not
113
+ * say; the API then defaults email itself, and SMS/LinkedIn need a sender picked.
114
+ */
115
+ export function replySender(thread: InboxThread): string {
116
+ if (thread.channel === "sms") return thread.from_number ?? "";
117
+ if (thread.channel === "linkedin") return thread.linkedin_account_id != null ? String(thread.linkedin_account_id) : "";
118
+ return thread.mailbox ?? "";
119
+ }
120
+
121
+ export interface SenderOption {
122
+ value: string;
123
+ label: string;
124
+ }
125
+
126
+ /**
127
+ * Senders for a picker from `GET /sms/phone-numbers` or `GET /inbox/linkedin/accounts`. Both answer with an
128
+ * untyped `{ items: [...] }` envelope (no response schema in the contract), so this reads defensively.
129
+ */
130
+ export function senderOptions(channel: string, payload: unknown): SenderOption[] {
131
+ const raw = Array.isArray(payload) ? payload : ((payload as { items?: unknown[] } | null)?.items ?? []);
132
+ const out: SenderOption[] = [];
133
+ for (const item of raw as Array<Record<string, unknown>>) {
134
+ if (!item || typeof item !== "object") continue;
135
+ if (channel === "sms") {
136
+ const number = item.phone_number ?? item.number ?? item.phone;
137
+ if (typeof number === "string" && number) out.push({ value: number, label: String(item.friendly_name ?? item.label ?? number) });
138
+ continue;
139
+ }
140
+ const id = item.id ?? item.account_id ?? item.linkedin_account_id;
141
+ if (typeof id !== "number" && !(typeof id === "string" && /^\d+$/.test(id))) continue;
142
+ const name = [item.first_name ?? item.firstName, item.last_name ?? item.lastName].filter(Boolean).join(" ");
143
+ out.push({ value: String(id), label: String(name || item.name || item.email || item.emailAddress || `Account ${id}`) });
144
+ }
145
+ return out;
146
+ }
@@ -32,11 +32,21 @@ const draft = await g8.api.aiInbox.generateInboxDraft({
32
32
  });
33
33
  const html = (draft.data as { content?: string }).content ?? "";
34
34
 
35
- // 4. EXTERNAL: send. Reuse the same idempotency key if you retry this exact call.
35
+ // 4. EXTERNAL: send from the thread's own mailbox (`mailbox`) to its contact. Reuse the same idempotency key if
36
+ // you retry this exact call. When `from_address` / `to` are omitted the API fills both from the thread, but
37
+ // graph8 APIs older than contract 1.374.0 refuse the send with 400, so name them.
38
+ const mailbox = full.data.mailbox ?? undefined;
39
+ const contactEmail = (full.data.contact as { email?: string | null } | null | undefined)?.email ?? undefined;
36
40
  const sent = await g8.api.inbox.sendReply(
37
41
  {
38
42
  path: { reply_id: thread.id },
39
- body: { channel: "email", body: html, subject: full.data.subject ? `Re: ${full.data.subject}` : null },
43
+ body: {
44
+ channel: "email",
45
+ body: html,
46
+ subject: full.data.subject ? `Re: ${full.data.subject}` : null,
47
+ from_address: mailbox,
48
+ to: contactEmail,
49
+ },
40
50
  },
41
51
  { idempotencyKey: randomUUID() },
42
52
  );
@@ -5,6 +5,15 @@ Apollo / Salesforce "integrations" settings screen.
5
5
 
6
6
  Depends on: `foundation`. Install: `npx @graph8/sdk add integrations`.
7
7
 
8
+ ## Screens
9
+
10
+ `IntegrationsScreen` (`components/integrations-screen.tsx`) is the finished page: copy it as your route. No props are required.
11
+
12
+ - **Connections tab**: the five CRMs and the workspace tools. "Manage sync" opens a side panel with sync switches, job progress and a manual
13
+ push (deep link `?id=hubspot`; a webhook is `?tab=webhooks&id=<webhook id>`). Connecting is a link out to the graph8 app, because OAuth consent only runs there.
14
+ - **Webhooks tab**: create, pause, rotate and delete webhooks, read deliveries. The secret is shown once.
15
+ - Props (all optional): `connectUrl`, `defaultWebhookUrl`, `pushRecords`, `toolbarActions`, `renderDetailActions(record)`, `extraDetailTabs` (each `{ id, label, render(record) }`), where `record` is `{ kind: "crm", provider }` or `{ kind: "webhook", id }`.
16
+
8
17
  ## Components
9
18
 
10
19
  | Component | What it does | Tiers |
@@ -85,6 +85,50 @@
85
85
  "external",
86
86
  "destructive"
87
87
  ]
88
+ },
89
+ {
90
+ "name": "IntegrationsScreen",
91
+ "file": "components/integrations-screen.tsx",
92
+ "purpose": "Finished integrations page: CRM and tool connections with a sync side panel, plus webhook management.",
93
+ "operations": [
94
+ "list_crm_syncs_crm_syncs_get",
95
+ "list_webhooks_webhooks_get"
96
+ ],
97
+ "tiers": [
98
+ "read"
99
+ ]
100
+ },
101
+ {
102
+ "name": "CrmDetail",
103
+ "file": "components/crm-detail.tsx",
104
+ "purpose": "Complete screen for one CRM integration: connection, sync switches, manual push, disconnect.",
105
+ "operations": [
106
+ "list_crm_syncs_crm_syncs_get",
107
+ "list_connections_integrations_connections_get",
108
+ "delete_connection_integrations_connections__connection_id__delete"
109
+ ],
110
+ "tiers": [
111
+ "read",
112
+ "destructive"
113
+ ]
114
+ },
115
+ {
116
+ "name": "WebhookDetail",
117
+ "file": "components/webhook-detail.tsx",
118
+ "purpose": "Complete screen for one webhook: every field, edit, deliveries, pause, rotate secret, delete.",
119
+ "operations": [
120
+ "list_webhooks_webhooks_get",
121
+ "list_webhook_events_webhooks_events_get",
122
+ "list_webhook_deliveries_webhooks__webhook_id__deliveries_get",
123
+ "update_webhook_webhooks__webhook_id__patch",
124
+ "rotate_webhook_secret_webhooks__webhook_id__rotate_secret_post",
125
+ "delete_webhook_webhooks__webhook_id__delete"
126
+ ],
127
+ "tiers": [
128
+ "read",
129
+ "external",
130
+ "destructive"
131
+ ]
88
132
  }
89
133
  ],
90
134
  "hooks": [],
@@ -167,6 +211,7 @@
167
211
  }
168
212
  ],
169
213
  "peerDependencies": {
170
- "react": ">=18"
214
+ "react": ">=18",
215
+ "lucide-react": ">=0.400"
171
216
  }
172
217
  }
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CrmDetail: the complete screen for one CRM integration (HubSpot, Salesforce, Pipedrive, Zoho, SugarCRM). Shows its connection
5
+ * (status, connected and last sync time, connection id), the continuous sync switches, job progress, manual push and contact fields
6
+ * (SyncStatus), and the actions: disconnect (destructive) when connected, or a link to connect in graph8 when it is not.
7
+ * Connecting is a link out: the OAuth consent screen only runs in the graph8 app.
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. listCrmSyncs GET /crm-syncs (read)
11
+ * 2. listConnections GET /integrations/connections (read; the connection to disconnect)
12
+ * 3. deleteConnection DELETE /integrations/connections/{id}?provider_config_key= (destructive, confirmed)
13
+ * 4. SyncStatus see sync-status.tsx (getCrmStatus, getCrmFields, set push/pull enabled, getSyncJob, pushContactsToCrm)
14
+ */
15
+ import type { ReactNode } from "react";
16
+ import { Skeleton } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
19
+ import { g8Delete, g8Get } from "@/g8/lib/g8-client";
20
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
21
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
22
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
23
+ import { StatusPill } from "@/g8/components/screen/status-pill";
24
+ import { arrayOf, formatWhen, GRAPH8_APP_INTEGRATIONS_PATH, PROVIDER_LABELS } from "../lib/integrations-utils";
25
+ import type { ConnectionRow, CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
26
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
27
+ import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
28
+ import { SyncStatus } from "./sync-status";
29
+ import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
30
+
31
+ export interface CrmDetailProps {
32
+ provider: CrmProvider;
33
+ /** Where "Connect in graph8" sends the person. */
34
+ connectUrl?: string;
35
+ /** Contact records (in the CRM's field names) offered for a manual push. */
36
+ records?: ReadonlyArray<Record<string, unknown>>;
37
+ onClose?: () => void;
38
+ onExpand?: () => void;
39
+ onCollapse?: () => void;
40
+ onChanged?: () => void;
41
+ /** Extra header actions from the screen's `renderDetailActions` slot. */
42
+ extraActions?: ReactNode;
43
+ /** Extra tabs from the screen's `extraDetailTabs` slot, after the built-in ones. */
44
+ extraTabs?: { id: string; label: string; content: ReactNode }[];
45
+ }
46
+
47
+ const CONNECTED = ["connected", "active", "ok", "healthy", "syncing", "synced", "enabled", "linked"];
48
+
49
+ export function CrmDetail({ provider, connectUrl: connectUrlProp, records, onClose, onExpand, onCollapse, onChanged, extraActions, extraTabs }: CrmDetailProps) {
50
+ const brand = useBrand();
51
+ const connectUrl = connectUrlProp ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
52
+ const { ask, dialog, failure } = useConfirmAction();
53
+ const syncs = useG8Query((signal) => g8Get("/crm-syncs", undefined, { signal }), []);
54
+ const connections = useG8Query((signal) => g8Get("/integrations/connections", undefined, { signal }), []);
55
+ const label = PROVIDER_LABELS[provider];
56
+
57
+ if (syncs.error) return <ApiError error={syncs.error} title="Could not load the integration" onRetry={() => void syncs.refetch()} />;
58
+ if (!syncs.data) return <Skeleton className="h-40 w-full" />;
59
+ const row = arrayOf<CrmSyncRow>(syncs.data).find((r) => r.provider?.toLowerCase() === provider);
60
+ const connected = Boolean(row && CONNECTED.includes(row.status?.toLowerCase()));
61
+ const all = arrayOf<ConnectionRow>(connections.data).filter((c) => (c.provider ?? "").toLowerCase() === provider || (c.provider_config_key ?? "").toLowerCase().split(/[-_]/)[0] === provider);
62
+ const connection = all.find((c) => row?.connection_id && c.connection_id === row.connection_id) ?? (all.length === 1 ? all[0] : undefined);
63
+ const changed = () => { void syncs.refetch(); void connections.refetch(); onChanged?.(); };
64
+
65
+ const actions = connected ? (
66
+ <AskButton ask={ask} label="Disconnect" variant="ghost" tier="destructive" title={`Disconnect ${label}`} confirmLabel="Disconnect"
67
+ disabled={!connection?.connection_id || !connection.provider_config_key}
68
+ description="Syncs configured against this connection stop working. Records already synced stay."
69
+ run={() => g8Delete(`/integrations/connections/${connection!.connection_id}`, { query: { provider_config_key: connection!.provider_config_key } })} onDone={changed} />
70
+ ) : (
71
+ <a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">Connect in {brand.linkLabel}</a>
72
+ );
73
+
74
+ const tabs = [
75
+ {
76
+ id: "overview",
77
+ label: "Overview",
78
+ content: (
79
+ <KeyValueList columns={2} items={[
80
+ { label: "Provider", value: label },
81
+ { label: "Status", value: row?.status ?? "not connected" },
82
+ { label: "Connected", value: formatWhen(row?.connected_at) },
83
+ { label: "Last sync", value: formatWhen(row?.last_sync_at) },
84
+ { label: "Connection", value: row?.connection_id ?? connection?.connection_id ?? "-" },
85
+ { label: "Config key", value: connection?.provider_config_key ?? "-" },
86
+ ]} />
87
+ ),
88
+ },
89
+ { id: "sync", label: "Sync", content: <SyncStatus provider={provider} records={records} /> },
90
+ ];
91
+ return (
92
+ <>
93
+ <DetailPanel title={label} subtitle="CRM integration" avatar={<EntityAvatar name={label} kind="company" size="lg" />}
94
+ badges={<StatusPill tone={connected ? "positive" : "neutral"}>{connected ? "Connected" : row?.status ?? "Not connected"}</StatusPill>}
95
+ actions={extraActions ? <>{actions}{extraActions}</> : actions} tabs={extraTabs?.length ? [...tabs, ...extraTabs] : tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
96
+ {failure ? <ApiError error={failure} title="That did not work" /> : null}
97
+ <ConfirmSpendDialog {...dialog} />
98
+ </>
99
+ );
100
+ }
@@ -26,17 +26,20 @@ import {
26
26
  arrayOf,
27
27
  CRM_PROVIDERS,
28
28
  formatWhen,
29
- GRAPH8_APP_INTEGRATIONS_URL,
29
+ GRAPH8_APP_INTEGRATIONS_PATH,
30
30
  looksConnected,
31
31
  PROVIDER_LABELS,
32
32
  } from "../lib/integrations-utils";
33
33
  import type { ConnectionRow, CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
34
+ import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
34
35
 
35
36
  export interface IntegrationListProps {
36
37
  /** Where "Connect" sends the person. Default: graph8 Settings > Integrations. */
37
38
  connectUrl?: string;
38
39
  /** Called when the person opens a CRM's sync panel (render SyncStatus for it). */
39
40
  onOpenSync?: (provider: CrmProvider) => void;
41
+ /** Called when a person opens any CRM, connected or not (show a CrmDetail). */
42
+ onSelectCrm?: (provider: CrmProvider) => void;
40
43
  className?: string;
41
44
  }
42
45
 
@@ -78,7 +81,9 @@ function ChatTools() {
78
81
  );
79
82
  }
80
83
 
81
- export function IntegrationList({ connectUrl = GRAPH8_APP_INTEGRATIONS_URL, onOpenSync, className }: IntegrationListProps) {
84
+ export function IntegrationList({ connectUrl: connectUrlProp, onOpenSync, onSelectCrm, className }: IntegrationListProps) {
85
+ const brand = useBrand();
86
+ const connectUrl = connectUrlProp ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
82
87
  const [disconnecting, setDisconnecting] = useState<{ provider: CrmProvider; connection: ConnectionRow } | null>(null);
83
88
  const syncs = useG8Query((signal) => g8Get("/crm-syncs", undefined, { signal }), []);
84
89
  const connections = useG8Query((signal) => g8Get("/integrations/connections", undefined, { signal }), []);
@@ -113,7 +118,7 @@ export function IntegrationList({ connectUrl = GRAPH8_APP_INTEGRATIONS_URL, onOp
113
118
  return (
114
119
  <li key={p} className="flex flex-wrap items-center justify-between gap-2 px-3 py-3" data-provider={p}>
115
120
  <div>
116
- <div className="font-medium">{PROVIDER_LABELS[p]}</div>
121
+ {onSelectCrm ? <button type="button" className="font-medium hover:underline" onClick={() => onSelectCrm(p)}>{PROVIDER_LABELS[p]}</button> : <div className="font-medium">{PROVIDER_LABELS[p]}</div>}
117
122
  <div className="text-xs text-gray-500">
118
123
  {connected ? `Last sync: ${formatWhen(row?.last_sync_at)}` : "Connect to sync contacts and companies."}
119
124
  </div>
@@ -137,7 +142,7 @@ export function IntegrationList({ connectUrl = GRAPH8_APP_INTEGRATIONS_URL, onOp
137
142
  ) : (
138
143
  <Button size="sm" asChild>
139
144
  <a href={connectUrl} target="_blank" rel="noreferrer">
140
- Connect in graph8
145
+ Connect in {brand.linkLabel}
141
146
  </a>
142
147
  </Button>
143
148
  )}
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ /**
4
+ * IntegrationsScreen: the finished integrations settings page. Two tabs:
5
+ * Connections the five CRMs and the workspace tools with their status (IntegrationList). Every CRM, connected or not, opens its
6
+ * complete detail screen (CrmDetail: connection, push/pull switches, job progress, manual push, disconnect; `?id=hubspot`).
7
+ * "Connect in graph8" links out: the OAuth consent screen only runs in the graph8 app.
8
+ * Webhooks create webhooks (WebhookManager); each opens its complete detail screen (WebhookDetail: every field, edit, deliveries,
9
+ * pause, rotate secret, delete; `?id=`). Every detail also opens as a full page with `?view=full`.
10
+ *
11
+ * SDK operations: two reads for the stat strip (GET /crm-syncs, GET /webhooks). Composes IntegrationList (read +
12
+ * destructive, confirmed), SyncStatus (external, confirmed) and WebhookManager (external + destructive, confirmed).
13
+ * Prerequisites: a CRM connected in the app (where: "app"); a public https route with G8_WEBHOOK_SECRET for webhooks.
14
+ */
15
+ import type { ReactNode } from "react";
16
+ import { useState } from "react";
17
+ import { Plug } from "lucide-react";
18
+ import { g8Get } from "@/g8/lib/g8-client";
19
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
20
+ import { useUrlState } from "@/g8/hooks/use-url-state";
21
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
22
+ import type { StatItem } from "@/g8/components/screen/stat-strip";
23
+ import { SplitView } from "@/g8/components/screen/split-view";
24
+ import { SectionCard } from "@/g8/components/screen/section-card";
25
+ import { arrayOf, CRM_PROVIDERS, GRAPH8_APP_INTEGRATIONS_PATH, PROVIDER_LABELS } from "../lib/integrations-utils";
26
+ import type { CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
27
+ import { IntegrationList } from "./integration-list";
28
+ import { CrmDetail } from "./crm-detail";
29
+ import { WebhookDetail } from "./webhook-detail";
30
+ import { WebhookManager } from "./webhook-manager";
31
+ import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
32
+
33
+ export interface IntegrationsScreenProps {
34
+ /** Where "Connect" sends people. Default: graph8 Settings > Integrations. */
35
+ connectUrl?: string;
36
+ /** Pre-fill the webhook endpoint URL (your /api/webhooks/graph8 route). */
37
+ defaultWebhookUrl?: string;
38
+ /** Contact records (in the CRM's field names) offered for a manual push in the sync panel. */
39
+ pushRecords?: ReadonlyArray<Record<string, unknown>>;
40
+ toolbarActions?: ReactNode;
41
+ /** Buttons added to the open record's header: a CRM connection or a webhook. */
42
+ renderDetailActions?: (record: IntegrationRecord) => ReactNode;
43
+ /** Tabs added after the built-in ones on the open record. */
44
+ extraDetailTabs?: { id: string; label: string; render: (record: IntegrationRecord) => ReactNode }[];
45
+ className?: string;
46
+ }
47
+
48
+ /** The record open in the detail panel, passed to the slot props. */
49
+ export type IntegrationRecord = { kind: "crm"; provider: CrmProvider } | { kind: "webhook"; id: string };
50
+
51
+ const CONNECTED = ["connected", "active", "ok", "healthy", "syncing", "synced", "enabled", "linked"];
52
+
53
+ function useStats(): StatItem[] | undefined {
54
+ const syncs = useG8Query((signal) => g8Get("/crm-syncs", undefined, { signal }), []);
55
+ const hooks = useG8Query((signal) => g8Get("/webhooks", undefined, { signal }), []);
56
+ if (syncs.error) return undefined;
57
+ const rows = arrayOf<CrmSyncRow>(syncs.data);
58
+ const connected = rows.filter((r) => CONNECTED.includes(r.status?.toLowerCase())).length;
59
+ const items: StatItem[] = [
60
+ { label: "CRMs connected", value: `${connected} of ${CRM_PROVIDERS.length}`, tone: connected ? "positive" : "neutral" },
61
+ ];
62
+ if (!hooks.error && hooks.data) items.push({ label: "Webhooks", value: arrayOf<unknown>(hooks.data).length });
63
+ return syncs.data ? items : undefined;
64
+ }
65
+
66
+ export function IntegrationsScreen(props: IntegrationsScreenProps) {
67
+ const brand = useBrand();
68
+ const connectUrl = props.connectUrl ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
69
+ const [tabValue, setTab] = useUrlState("tab", "connections");
70
+ const tab = tabValue === "webhooks" ? "webhooks" : "connections";
71
+ const [id, setId] = useUrlState("id", "");
72
+ const [view, setView] = useUrlState("view", "");
73
+ const expanded = view === "full" && Boolean(id);
74
+ const [tick, setTick] = useState(0);
75
+ const stats = useStats();
76
+ const close = () => {
77
+ setId("");
78
+ setView("");
79
+ };
80
+ const panel = {
81
+ onClose: close,
82
+ onExpand: expanded ? undefined : () => setView("full"),
83
+ onCollapse: expanded ? () => setView("") : undefined,
84
+ onChanged: () => setTick((t) => t + 1),
85
+ };
86
+ const provider = (CRM_PROVIDERS as readonly string[]).includes(id) ? (id as CrmProvider) : null;
87
+ const slots = (record: IntegrationRecord) => ({
88
+ extraActions: props.renderDetailActions?.(record),
89
+ extraTabs: (props.extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(record) })),
90
+ });
91
+ const split = (list: ReactNode, detail: ReactNode, open: boolean) => (
92
+ <SplitView detailOpen={open} onCloseDetail={close} expanded={expanded} onExpandedChange={(next) => setView(next ? "full" : "")} detailWidth="lg" list={list} detail={open ? detail : null} />
93
+ );
94
+
95
+ return (
96
+ <ScreenLayout
97
+ title="Integrations"
98
+ description={`Connect your CRM, keep it in sync, and send ${brand.linkLabel} events to your own systems.`}
99
+ icon={<Plug className="size-5" />}
100
+ actions={props.toolbarActions}
101
+ tabs={[
102
+ { id: "connections", label: "Connections" },
103
+ { id: "webhooks", label: "Webhooks" },
104
+ ]}
105
+ activeTab={tab}
106
+ onTabChange={(next) => {
107
+ setTab(next);
108
+ close();
109
+ }}
110
+ stats={stats}
111
+ className={props.className}
112
+ >
113
+ {tab === "connections" ? (
114
+ <div className="space-y-4">
115
+ <SectionCard title="Connecting an account" description={`Sign-in with your CRM happens in ${brand.linkLabel}. Connect there, then manage the sync here.`}>
116
+ <a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">
117
+ Connect in {brand.linkLabel}
118
+ </a>
119
+ </SectionCard>
120
+ {split(
121
+ <IntegrationList key={tick} connectUrl={connectUrl} onSelectCrm={(p) => setId(p)} onOpenSync={(p) => setId(p)} />,
122
+ provider ? <CrmDetail key={provider} provider={provider} connectUrl={connectUrl} records={props.pushRecords} {...panel} {...slots({ kind: "crm", provider })} /> : null,
123
+ provider != null,
124
+ )}
125
+ </div>
126
+ ) : (
127
+ split(
128
+ <WebhookManager key={tick} defaultUrl={props.defaultWebhookUrl} onSelectWebhook={(h) => setId(h.id)} />,
129
+ <WebhookDetail key={id} webhookId={id} {...panel} {...slots({ kind: "webhook", id })} />,
130
+ Boolean(id),
131
+ )
132
+ )}
133
+ </ScreenLayout>
134
+ );
135
+ }