@graph8/sdk 0.361.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 (388) hide show
  1. package/README.md +14 -10
  2. package/blocks/_foundation/README.md +172 -1
  3. package/blocks/_foundation/block.json +234 -13
  4. package/blocks/_foundation/components/api-error.tsx +71 -25
  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/empty-state.tsx +39 -7
  9. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  10. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  11. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  12. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  13. package/blocks/_foundation/components/screen/index.ts +22 -0
  14. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  15. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  16. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  17. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  18. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  19. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  20. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  21. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  22. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  23. package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
  24. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  25. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  26. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  27. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  28. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  29. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  30. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  31. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  32. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  33. package/blocks/_foundation/lib/g8-client.ts +1 -1
  34. package/blocks/_foundation/lib/plural.ts +23 -0
  35. package/blocks/_foundation/server/connect-route.ts +15 -0
  36. package/blocks/_foundation/server/connect.ts +127 -0
  37. package/blocks/_foundation/server/local-key.ts +70 -0
  38. package/blocks/_foundation/server/proxy.ts +103 -3
  39. package/blocks/_foundation/server/route.ts +16 -4
  40. package/blocks/agents/README.md +14 -0
  41. package/blocks/agents/block.json +33 -1
  42. package/blocks/agents/components/agent-builder.tsx +13 -8
  43. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
  44. package/blocks/agents/components/agent-run-detail.tsx +102 -0
  45. package/blocks/agents/components/agent-runs.tsx +16 -7
  46. package/blocks/agents/components/agents-screen.tsx +184 -0
  47. package/blocks/analytics/README.md +13 -0
  48. package/blocks/analytics/block.json +45 -1
  49. package/blocks/analytics/components/analytics-screen.tsx +209 -0
  50. package/blocks/analytics/components/leaderboard.tsx +14 -7
  51. package/blocks/analytics/components/report-builder.tsx +23 -9
  52. package/blocks/analytics/components/report-detail.tsx +84 -0
  53. package/blocks/analytics/components/trend-chart.tsx +62 -0
  54. package/blocks/appointments/README.md +21 -1
  55. package/blocks/appointments/block.json +43 -2
  56. package/blocks/appointments/components/availability-editor.tsx +17 -5
  57. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  58. package/blocks/appointments/components/event-type-related.tsx +77 -0
  59. package/blocks/appointments/components/meeting-list.tsx +14 -6
  60. package/blocks/appointments/components/meetings-screen.tsx +157 -0
  61. package/blocks/appointments/components/scheduling-screen.tsx +122 -0
  62. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  63. package/blocks/block.schema.json +18 -0
  64. package/blocks/block.types.ts +26 -0
  65. package/blocks/campaigns/README.md +41 -11
  66. package/blocks/campaigns/block.json +105 -42
  67. package/blocks/campaigns/components/campaign-assets.tsx +16 -5
  68. package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
  69. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  70. package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
  71. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  72. package/blocks/campaigns/components/icp-detail.tsx +154 -0
  73. package/blocks/campaigns/components/icp-editor.tsx +15 -15
  74. package/blocks/campaigns/components/persona-detail.tsx +172 -0
  75. package/blocks/campaigns/components/persona-editor.tsx +14 -11
  76. package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
  77. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  78. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  79. package/blocks/coach/README.md +10 -0
  80. package/blocks/coach/block.json +16 -1
  81. package/blocks/coach/components/coach-screen.tsx +222 -0
  82. package/blocks/copilot/README.md +13 -0
  83. package/blocks/copilot/block.json +14 -1
  84. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  85. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  86. package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
  87. package/blocks/crm/README.md +28 -0
  88. package/blocks/crm/block.json +58 -2
  89. package/blocks/crm/components/companies-screen.tsx +345 -0
  90. package/blocks/crm/components/company-detail.tsx +8 -6
  91. package/blocks/crm/components/company-table.tsx +11 -4
  92. package/blocks/crm/components/contact-detail.tsx +6 -3
  93. package/blocks/crm/components/contact-table.tsx +10 -3
  94. package/blocks/crm/components/contacts-screen.tsx +359 -0
  95. package/blocks/crm/components/dedupe-review.tsx +7 -5
  96. package/blocks/crm/components/import-wizard.tsx +4 -2
  97. package/blocks/crm/components/notes-panel.tsx +4 -4
  98. package/blocks/crm/components/record-form.tsx +5 -3
  99. package/blocks/crm/components/task-list.tsx +4 -4
  100. package/blocks/crm/components/tasks-screen.tsx +379 -0
  101. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  102. package/blocks/crm/hooks/use-page-query.ts +7 -1
  103. package/blocks/crm/lib/crm-types.ts +12 -2
  104. package/blocks/crm/lib/screen-parts.tsx +105 -0
  105. package/blocks/deals/README.md +34 -3
  106. package/blocks/deals/block.json +45 -4
  107. package/blocks/deals/components/deal-detail.tsx +27 -13
  108. package/blocks/deals/components/deal-related.tsx +87 -0
  109. package/blocks/deals/components/deals-screen.tsx +298 -0
  110. package/blocks/deals/components/pipeline-board.tsx +4 -2
  111. package/blocks/deals/components/stage-editor.tsx +4 -4
  112. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  113. package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
  114. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  115. package/blocks/deals/lib/deals-types.ts +14 -0
  116. package/blocks/dialer/README.md +34 -2
  117. package/blocks/dialer/block.json +67 -1
  118. package/blocks/dialer/components/call-contact-button.tsx +252 -0
  119. package/blocks/dialer/components/call-log-screen.tsx +205 -0
  120. package/blocks/dialer/components/call-log.tsx +7 -3
  121. package/blocks/dialer/components/call-queue.tsx +1 -1
  122. package/blocks/dialer/components/dialer-screen.tsx +319 -0
  123. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  124. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  125. package/blocks/dialer/components/session-start-form.tsx +4 -2
  126. package/blocks/dialer/components/softphone.tsx +3 -1
  127. package/blocks/enrichment/README.md +14 -2
  128. package/blocks/enrichment/block.json +32 -4
  129. package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
  130. package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
  131. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  132. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  133. package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
  134. package/blocks/enrichment/components/waterfall-config.tsx +39 -10
  135. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  136. package/blocks/forms/README.md +12 -0
  137. package/blocks/forms/block.json +39 -17
  138. package/blocks/forms/components/form-builder.tsx +3 -1
  139. package/blocks/forms/components/form-embed.tsx +3 -1
  140. package/blocks/forms/components/forms-screen.tsx +353 -0
  141. package/blocks/forms/components/submissions-table.tsx +16 -6
  142. package/blocks/inbox/README.md +27 -3
  143. package/blocks/inbox/block.json +14 -2
  144. package/blocks/inbox/components/inbox-list.tsx +7 -3
  145. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  146. package/blocks/inbox/components/reply-composer.tsx +65 -9
  147. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  148. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  149. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  150. package/blocks/inbox/lib/inbox-types.ts +47 -1
  151. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  152. package/blocks/integrations/README.md +9 -0
  153. package/blocks/integrations/block.json +46 -1
  154. package/blocks/integrations/components/crm-detail.tsx +100 -0
  155. package/blocks/integrations/components/integration-list.tsx +9 -4
  156. package/blocks/integrations/components/integrations-screen.tsx +135 -0
  157. package/blocks/integrations/components/sync-status.tsx +5 -3
  158. package/blocks/integrations/components/webhook-detail.tsx +160 -0
  159. package/blocks/integrations/components/webhook-manager.tsx +13 -8
  160. package/blocks/integrations/lib/integrations-utils.ts +6 -1
  161. package/blocks/intent/README.md +14 -0
  162. package/blocks/intent/block.json +27 -1
  163. package/blocks/intent/components/intent-feed.tsx +5 -2
  164. package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
  165. package/blocks/intent/components/intent-screen.tsx +265 -0
  166. package/blocks/intent/components/keyword-detail.tsx +124 -0
  167. package/blocks/lifecycle/README.md +16 -0
  168. package/blocks/lifecycle/block.json +34 -7
  169. package/blocks/lifecycle/components/account-related.tsx +66 -0
  170. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  171. package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
  172. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  173. package/blocks/lists/README.md +13 -0
  174. package/blocks/lists/block.json +39 -1
  175. package/blocks/lists/components/add-to-list-button.tsx +3 -2
  176. package/blocks/lists/components/list-picker.tsx +5 -1
  177. package/blocks/lists/components/list-table.tsx +11 -5
  178. package/blocks/lists/components/lists-screen.tsx +344 -0
  179. package/blocks/lists/components/suppression-manager.tsx +6 -2
  180. package/blocks/lists/components/suppressions-screen.tsx +255 -0
  181. package/blocks/mailboxes/README.md +17 -0
  182. package/blocks/mailboxes/block.json +65 -17
  183. package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
  184. package/blocks/mailboxes/components/domain-detail.tsx +142 -0
  185. package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
  186. package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
  187. package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
  188. package/blocks/mailboxes/components/warmup-health.tsx +4 -2
  189. package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
  190. package/blocks/newsletter/README.md +13 -0
  191. package/blocks/newsletter/block.json +47 -23
  192. package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
  193. package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
  194. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  195. package/blocks/nurture/README.md +12 -0
  196. package/blocks/nurture/block.json +35 -13
  197. package/blocks/nurture/components/nurture-builder.tsx +3 -1
  198. package/blocks/nurture/components/nurture-enroll.tsx +6 -3
  199. package/blocks/nurture/components/nurture-form.tsx +23 -3
  200. package/blocks/nurture/components/nurture-screen.tsx +283 -0
  201. package/blocks/nurture/lib/nurture-types.ts +1 -1
  202. package/blocks/objects/README.md +17 -0
  203. package/blocks/objects/block.json +29 -16
  204. package/blocks/objects/components/object-not-found.tsx +3 -1
  205. package/blocks/objects/components/object-record-detail.tsx +2 -1
  206. package/blocks/objects/components/object-schema-editor.tsx +3 -1
  207. package/blocks/objects/components/object-table.tsx +13 -3
  208. package/blocks/objects/components/objects-screen.tsx +147 -0
  209. package/blocks/objects/lib/object-types.ts +1 -1
  210. package/blocks/pages/README.md +12 -0
  211. package/blocks/pages/block.json +35 -22
  212. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  213. package/blocks/pages/components/content-library.tsx +46 -11
  214. package/blocks/pages/components/page-list.tsx +6 -3
  215. package/blocks/pages/components/pages-screen.tsx +154 -0
  216. package/blocks/quotes/README.md +17 -0
  217. package/blocks/quotes/block.json +38 -9
  218. package/blocks/quotes/components/product-picker.tsx +3 -1
  219. package/blocks/quotes/components/quote-list.tsx +7 -3
  220. package/blocks/quotes/components/quote-related.tsx +123 -0
  221. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  222. package/blocks/radar/README.md +10 -0
  223. package/blocks/radar/block.json +41 -1
  224. package/blocks/radar/components/competitor-detail.tsx +117 -0
  225. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  226. package/blocks/radar/components/radar-report.tsx +4 -2
  227. package/blocks/radar/components/radar-screen.tsx +214 -0
  228. package/blocks/radar/components/report-detail.tsx +104 -0
  229. package/blocks/search/README.md +10 -0
  230. package/blocks/search/block.json +16 -1
  231. package/blocks/search/components/filter-builder.tsx +3 -3
  232. package/blocks/search/components/prospect-results.tsx +5 -3
  233. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  234. package/blocks/search/components/saved-searches.tsx +4 -1
  235. package/blocks/search/hooks/use-filter-options.ts +20 -6
  236. package/blocks/search/lib/search-types.ts +2 -0
  237. package/blocks/sequencer/README.md +49 -9
  238. package/blocks/sequencer/block.json +99 -19
  239. package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
  240. package/blocks/sequencer/components/sequence-builder.tsx +211 -46
  241. package/blocks/sequencer/components/sequence-detail.tsx +259 -0
  242. package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
  243. package/blocks/sequencer/components/sequence-list.tsx +33 -8
  244. package/blocks/sequencer/components/sequence-stats.tsx +3 -2
  245. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  246. package/blocks/sequencer/components/sequences-screen.tsx +159 -0
  247. package/blocks/sequencer/components/step-editor.tsx +3 -1
  248. package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
  249. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  250. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  251. package/blocks/social-ads/README.md +11 -0
  252. package/blocks/social-ads/block.json +101 -2
  253. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  254. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  255. package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
  256. package/blocks/social-ads/components/audience-sync.tsx +11 -7
  257. package/blocks/social-ads/components/campaign-detail.tsx +133 -0
  258. package/blocks/social-ads/components/creative-detail.tsx +150 -0
  259. package/blocks/social-ads/components/creative-gallery.tsx +15 -5
  260. package/blocks/social-ads/components/post-composer.tsx +3 -1
  261. package/blocks/social-ads/components/post-detail.tsx +160 -0
  262. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  263. package/blocks/social-ads/components/social-screen.tsx +160 -0
  264. package/blocks/visitors/README.md +10 -0
  265. package/blocks/visitors/block.json +25 -1
  266. package/blocks/visitors/components/snippet-install.tsx +3 -1
  267. package/blocks/visitors/components/visitor-company-card.tsx +2 -2
  268. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  269. package/blocks/visitors/components/visitor-feed.tsx +11 -2
  270. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  271. package/blocks/webchat/README.md +10 -0
  272. package/blocks/webchat/block.json +21 -7
  273. package/blocks/webchat/components/chat-widget-config.tsx +7 -4
  274. package/blocks/webchat/components/conversation-list.tsx +7 -3
  275. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  276. package/blocks/workbench/README.md +11 -0
  277. package/blocks/workbench/block.json +48 -1
  278. package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
  279. package/blocks/workbench/components/workbench-grid.tsx +9 -3
  280. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  281. package/blocks/workbench/components/workbench-screen.tsx +192 -0
  282. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  283. package/blocks/workflows/README.md +13 -0
  284. package/blocks/workflows/block.json +28 -9
  285. package/blocks/workflows/components/run-history.tsx +31 -9
  286. package/blocks/workflows/components/trigger-config.tsx +3 -1
  287. package/blocks/workflows/components/workflow-editor.tsx +3 -1
  288. package/blocks/workflows/components/workflow-list.tsx +13 -6
  289. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  290. package/dist/cli.js +2785 -1637
  291. package/dist/errors.d.mts +34 -0
  292. package/dist/errors.d.ts +34 -0
  293. package/dist/errors.js +46 -0
  294. package/dist/errors.js.map +1 -0
  295. package/dist/errors.mjs +21 -0
  296. package/dist/errors.mjs.map +1 -0
  297. package/dist/index.browser.js +11 -5
  298. package/dist/index.browser.js.map +1 -1
  299. package/dist/index.browser.mjs +11 -5
  300. package/dist/index.browser.mjs.map +1 -1
  301. package/dist/index.d.mts +164 -25
  302. package/dist/index.d.ts +164 -25
  303. package/dist/index.js +11 -5
  304. package/dist/index.js.map +1 -1
  305. package/dist/index.mjs +11 -5
  306. package/dist/index.mjs.map +1 -1
  307. package/dist/react.browser.js +10 -4
  308. package/dist/react.browser.js.map +1 -1
  309. package/dist/react.browser.mjs +10 -4
  310. package/dist/react.browser.mjs.map +1 -1
  311. package/dist/react.d.mts +142 -5
  312. package/dist/react.d.ts +142 -5
  313. package/dist/react.js +10 -4
  314. package/dist/react.js.map +1 -1
  315. package/dist/react.mjs +10 -4
  316. package/dist/react.mjs.map +1 -1
  317. package/dist/types/blocks/add.d.ts +11 -4
  318. package/dist/types/blocks/brand.d.ts +113 -0
  319. package/dist/types/blocks/compose.d.ts +53 -0
  320. package/dist/types/blocks/create.d.ts +25 -8
  321. package/dist/types/blocks/doctor.d.ts +6 -1
  322. package/dist/types/blocks/flows.d.ts +20 -0
  323. package/dist/types/blocks/navfile.d.ts +35 -0
  324. package/dist/types/blocks/paths.d.ts +4 -0
  325. package/dist/types/blocks/prefixes.d.ts +37 -0
  326. package/dist/types/blocks/registry.d.ts +32 -0
  327. package/dist/types/blocks/screens.d.ts +53 -0
  328. package/dist/types/cli.d.ts +2 -0
  329. package/dist/types/errors.d.ts +33 -0
  330. package/dist/types/http.d.ts +2 -18
  331. package/dist/types/version.d.ts +2 -2
  332. package/docs/agents.md +15 -4
  333. package/docs/api/README.md +2 -2
  334. package/docs/api/by-path.md +1 -0
  335. package/docs/api/desk.d.ts +98 -2
  336. package/docs/api/desk.md +123 -1
  337. package/docs/api/inbox.d.ts +10 -2
  338. package/docs/api/inbox.md +10 -2
  339. package/docs/api/seo.d.ts +19 -1
  340. package/docs/api/seo.md +24 -1
  341. package/package.json +16 -2
  342. package/stacks/6sense.json +41 -16
  343. package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
  344. package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
  345. package/stacks/apollo.json +118 -36
  346. package/stacks/calendly.json +60 -0
  347. package/stacks/clay/pages/companies.tsx.tmpl +15 -0
  348. package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
  349. package/stacks/clay.json +59 -26
  350. package/stacks/gong.json +81 -35
  351. package/stacks/hootsuite.json +99 -0
  352. package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
  353. package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
  354. package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
  355. package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
  356. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  357. package/stacks/hubspot.json +167 -66
  358. package/stacks/intercom.json +48 -20
  359. package/stacks/mailchimp.json +52 -33
  360. package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
  361. package/stacks/outreach.json +88 -25
  362. package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
  363. package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
  364. package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
  365. package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
  366. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  367. package/stacks/pipedrive.json +115 -0
  368. package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
  369. package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
  370. package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
  371. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  372. package/stacks/salesforce.json +81 -31
  373. package/stacks/stack.schema.json +6 -1
  374. package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
  375. package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
  376. package/stacks/zoominfo.json +102 -0
  377. package/styles/styles.css +1 -1
  378. package/templates/next-app/README.md.tmpl +55 -8
  379. package/templates/next-app/app/globals.css.tmpl +105 -0
  380. package/templates/next-app/app/layout.tsx.tmpl +18 -4
  381. package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
  382. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  383. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  384. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  385. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  386. package/templates/next-app/dot-gitignore.tmpl +2 -0
  387. package/templates/next-app/lib/nav.ts.tmpl +1 -1
  388. package/templates/next-app/package.json.tmpl +1 -1
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useDefaultOwner: who owns a deal created in the UI when nobody picked an owner.
5
+ *
6
+ * graph8 requires an owner on every deal. The rule (documented in the README defaults, so no agent has to ask):
7
+ * 1. the API key's own user: GET /team-members/me (roster record of the person who minted a PERSONAL key);
8
+ * 2. otherwise the first active team member of GET /team-members (an organisation key has no person, and
9
+ * /team-members/me answers 403 for it), which the screen shows in an owner picker the person can change.
10
+ *
11
+ * SDK operations, in order:
12
+ * 1. GET /team-members/me (teamMembers.getMyTeamMember) read - may be refused for an organisation key
13
+ * 2. GET /team-members (teamMembers.listTeamMembers) read - the picker options
14
+ *
15
+ * `owner_id` accepts a user id or an email; this hook prefers the identity-provider user id and falls back to the email.
16
+ */
17
+ import { g8Get } from "@/g8/lib/g8-client";
18
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
19
+ import { dataOf } from "../lib/deals-types";
20
+
21
+ interface TeamMember {
22
+ id?: string;
23
+ name?: string | null;
24
+ email?: string | null;
25
+ propelauth_user_id?: string | null;
26
+ status?: string | null;
27
+ }
28
+
29
+ export interface OwnerOption {
30
+ /** The value to send as `owner_id`. */
31
+ id: string;
32
+ name: string;
33
+ email?: string | null;
34
+ }
35
+
36
+ export type OwnerSource = "key" | "first-member" | "none";
37
+
38
+ function toOption(m: TeamMember | null | undefined): OwnerOption | null {
39
+ const id = m?.propelauth_user_id || m?.email || null;
40
+ return m && id ? { id, name: m.name || m.email || id, email: m.email } : null;
41
+ }
42
+
43
+ function membersOf(env: unknown): TeamMember[] {
44
+ const inner = dataOf<unknown>(env);
45
+ if (Array.isArray(inner)) return inner as TeamMember[];
46
+ const o = inner as { items?: unknown; team_members?: unknown } | null;
47
+ const list = o?.items ?? o?.team_members;
48
+ return Array.isArray(list) ? (list as TeamMember[]) : [];
49
+ }
50
+
51
+ export function useDefaultOwner() {
52
+ const me = useG8Query(async (signal) => toOption(dataOf<TeamMember>(await g8Get("/team-members/me", undefined, { signal }))), []);
53
+ const team = useG8Query(async (signal) => membersOf(await g8Get("/team-members", { limit: 200 }, { signal })).filter((m) => !m.status || m.status === "active").map(toOption).filter((o): o is OwnerOption => o !== null), []);
54
+ const mine = me.data ?? null; // a refused /team-members/me (organisation key) is expected, not an error to show
55
+ const owners = team.data ?? [];
56
+ const all = mine && !owners.some((o) => o.id === mine.id) ? [mine, ...owners] : owners;
57
+ const defaultOwner = mine ?? all[0] ?? null;
58
+ const source: OwnerSource = mine ? "key" : defaultOwner ? "first-member" : "none";
59
+ return { owners: all, defaultOwner, source, loading: me.loading || (team.loading && !defaultOwner), error: all.length === 0 ? team.error : undefined, reload: team.refetch };
60
+ }
@@ -15,6 +15,7 @@
15
15
  import { useCallback, useEffect, useState } from "react";
16
16
  import { g8Get, g8Patch } from "@/g8/lib/g8-client";
17
17
  import { useG8Query } from "@/g8/hooks/use-g8-query";
18
+ import { invalidate } from "@/g8/lib/g8-cache";
18
19
  import type { Deal } from "../lib/deals-types";
19
20
  import { dataOf } from "../lib/deals-types";
20
21
 
@@ -42,7 +43,7 @@ export function usePipelineDeals(pipelineId: string | undefined, filters: Pipeli
42
43
  return all.slice(0, maxDeals);
43
44
  },
44
45
  [pipelineId, filters.search, filters.ownerId, maxDeals],
45
- { enabled: Boolean(pipelineId) },
46
+ { enabled: Boolean(pipelineId), key: ["deals", "list", "board", { pipelineId, search: filters.search || undefined, ownerId: filters.ownerId || undefined, maxDeals }] },
46
47
  );
47
48
  useEffect(() => { if (query.data) setDeals(query.data); }, [query.data]);
48
49
 
@@ -53,6 +54,9 @@ export function usePipelineDeals(pipelineId: string | undefined, filters: Pipeli
53
54
  try {
54
55
  const saved = dataOf<Deal>(await g8Patch(`/deals/${encodeURIComponent(deal.id)}`, { stage_id: stageId, expected_revision: deal.revision ?? undefined, ...extra }));
55
56
  setDeals((all) => all.map((d) => (d.id === deal.id ? { ...d, ...saved } : d)));
57
+ // The deal's revision changed: a cached detail or history of it is out of date.
58
+ void invalidate(["deals", "detail"]);
59
+ void invalidate(["deals", "history"]);
56
60
  return saved;
57
61
  } catch (err) {
58
62
  // Roll back by re-reading the server's truth: a snapshot taken before the move could itself be stale.
@@ -13,7 +13,7 @@ export function usePipelines() {
13
13
  const q = useG8Query(async (signal) => {
14
14
  const env = await g8Get<Pipeline[]>("/deals/pipelines", undefined, { signal });
15
15
  return (env.data ?? []).map((p) => ({ ...p, stages: sortedStages(p) }));
16
- }, []);
16
+ }, [], { key: ["deals", "pipelines"] });
17
17
  const pipelines = q.data ?? [];
18
18
  return { ...q, pipelines, defaultPipeline: pipelines.find((p) => p.is_default) ?? pipelines[0] };
19
19
  }
@@ -80,6 +80,20 @@ export function sumAmounts(deals: ReadonlyArray<Pick<Deal, "amount">>): number {
80
80
  return deals.reduce((total, d) => total + (d.amount ?? 0), 0);
81
81
  }
82
82
 
83
+ /**
84
+ * Money for a group of deals that may span currencies: one formatted total per currency, joined with " + "
85
+ * ("$1,200 + €300"). A single currency formats exactly like formatMoney. `valueOf` defaults to the amount sum.
86
+ */
87
+ export function sumMoney(deals: ReadonlyArray<Deal>, valueOf: (group: ReadonlyArray<Deal>) => number = sumAmounts, fallbackCurrency?: string | null): string {
88
+ const groups = new Map<string, Deal[]>();
89
+ for (const d of deals) {
90
+ const key = d.currency || "USD";
91
+ groups.set(key, [...(groups.get(key) ?? []), d]);
92
+ }
93
+ if (groups.size <= 1) return formatMoney(valueOf(deals), deals[0]?.currency ?? fallbackCurrency);
94
+ return [...groups].map(([currency, group]) => formatMoney(valueOf(group), currency)).join(" + ");
95
+ }
96
+
83
97
  /**
84
98
  * Probability-weighted value of open deals. This is an ESTIMATE computed here: the analytics endpoint refuses to
85
99
  * return a weighted total because a stage's probability defaults to 0 when nobody configured it, which would
@@ -9,6 +9,26 @@ browser, record outcomes, and review transcripts. Copy `DialerWorkspace` for the
9
9
  > voicemail, or mints a softphone credential without a confirm dialog first, and the block never auto-advances to
10
10
  > the next batch.
11
11
 
12
+ ## Screens
13
+
14
+ - `DialerScreen` (`components/dialer-screen.tsx`): **Session** tab picks a list (ListPicker), creates a session (dials
15
+ nobody), then runs the queue: every batch, voicemail drop, softphone credential and stop is confirmed; finished calls
16
+ open the outcome panel and transcript. **Sessions** tab lists every session; a row opens `DialerSessionDetail` (stats, details, calls, queue; pause, confirmed resume, confirmed stop; `?tab=sessions&session=<id>`). **History** tab lists the rep's calls. Props are optional: `userId` (needed to
17
+ enable the browser softphone), `userEmail`, `listId`, `sessionId`, `transport` (default: the Twilio adapter, loaded
18
+ on demand, needs `@twilio/voice-sdk`). Resume a session with `?session=<id>`. Add `&view=full` to any detail (session, call, and the same in the other screens) to open it as a full page.
19
+ - `CallLogScreen` (`components/call-log-screen.tsx`): history of one rep, contact or session with outcome filter and search;
20
+ open a call for recording, transcript and AI grade (`?id=<room_name>`), and **Call back** the number (`CallContactButton`). Needs a rep email (it asks) or a prop.
21
+ - Slots on both: `toolbarActions`, `renderRowActions`, `renderDetailActions`, `extraDetailTabs`.
22
+ - `CallContactButton` (`components/call-contact-button.tsx`): **call one contact from any screen**. Drop it into a
23
+ contact page's `renderDetailActions` (the apollo and outreach stacks do) or a row slot: `<CallContactButton contact={c} />`.
24
+ Closed, it loads nothing. Open: pick the number (masked `***` numbers are never offered), a PAUSED or COMPLETED
25
+ session to log the call under, and the caller ID; turn on the softphone; confirm (external, 1 recipient). It calls
26
+ `POST /dialer/parallel-calls/manual-dial` and the softphone joins the returned `conference_name`. The rep is
27
+ `userId` if passed, else the personal key's user (`GET /team-members/me`); an organisation key is not a rep, so it
28
+ then asks for `userId`. Why not `next-batch` with `force_next_contact`: that only picks a contact already in the
29
+ session's list and dials a whole batch.
30
+ Scopes: `voice:read`, `voice:write`, `voice:run` (and `team:read` for `CallContactButton` without `userId`). A phone number must exist (buy or verify it in the graph8 app).
31
+
12
32
  ## How it works (read this before building)
13
33
 
14
34
  - graph8 places the calls **from its servers** into a conference. The rep's browser only **joins that
@@ -81,13 +101,22 @@ and the call log for a rep email. Pass `userId` / `userEmail` from your session
81
101
  - Voicemail drop plays the rep's cloned voice to a stranger's phone. The confirm dialog names the contact.
82
102
  - Another tenant's session id is a 404, the same as a missing one.
83
103
  - Manual dial (`POST /dialer/parallel-calls/manual-dial`, external) needs a PAUSED or COMPLETED session and can
84
- create a contact; it is a server recipe here, not a component.
104
+ create a contact (an unknown number is matched to a contact or one is created). `CallContactButton` uses it.
105
+ - **Late join.** The conference name of a batch is only known from the `next-batch` (or `manual-dial`) response; the
106
+ app learns the NEXT batch's name earlier over a WebSocket the API does not have, and the session read does not
107
+ expose it. Voice waits up to 2 s for the rep only when the name was announced, so an SDK softphone joins after the
108
+ dials go out and the first second of an instant answer can be missed (voice's late-join reconciliation still
109
+ connects the audio). Keep the softphone registered BEFORE dialing (`CallContactButton` only enables the call once
110
+ it is ready) so the join is a single `joinConference` the moment the response lands.
111
+ - `next-batch` `user_email`: with a personal key the dialer's rep is the key's own user (naming someone else is a
112
+ 403); with an organisation key the name is passed through but not vouched for, so voice keeps dialing without
113
+ moving the session to that rep.
85
114
 
86
115
  ## Operations
87
116
 
88
117
  | Operation | Method and path | Tier | Used by |
89
118
  |---|---|---|---|
90
- | `voice.listDialerNumbers` | GET /voice/dialer/numbers | read | SessionStartForm |
119
+ | `voice.listDialerNumbers` | GET /voice/dialer/numbers | read | SessionStartForm, CallContactButton |
91
120
  | `dialer.createSessionDraft` | POST /dialer/sessions/draft | write | SessionStartForm |
92
121
  | `dialer.readQueueStats` | GET /dialer/sessions/{id}/queue-stats | read | useDialerSession |
93
122
  | `voice.getDialerSession` | GET /dialer/sessions/{id} | read | useDialerSession |
@@ -106,6 +135,9 @@ and the call log for a rep email. Pass `userId` / `userEmail` from your session
106
135
  | `voice.getCallTranscript` | GET /voice/dialer/calls/{room}/transcript | read | TranscriptViewer |
107
136
  | `voice.getCallGrading` | GET /voice/dialer/calls/{room}/grading | read | TranscriptViewer |
108
137
  | `voice.downloadCallRecording` | GET /voice/calls/{id}/recording | read | TranscriptViewer |
138
+ | `teamMembers.getMyTeamMember` | GET /team-members/me | read | CallContactButton (only without `userId`) |
139
+ | `voice.listDialerSessions` | GET /voice/dialer/sessions | read | CallContactButton |
140
+ | `dialer.manualDial` | POST /dialer/parallel-calls/manual-dial | external | CallContactButton |
109
141
 
110
142
  ## Recipes (server side)
111
143
 
@@ -21,15 +21,19 @@
21
21
  "get_call_grading_voice_dialer_calls__room_name__grading_get",
22
22
  "get_call_transcript_voice_dialer_calls__room_name__transcript_get",
23
23
  "get_dialer_session_dialer_sessions__session_id__get",
24
+ "get_my_team_member_team_members_me_get",
24
25
  "hangup_session_call_dialer_sessions_calls_hangup_post",
25
26
  "list_calls_for_contact_voice_dialer_calls_get",
26
27
  "list_dialer_numbers_voice_dialer_numbers_get",
28
+ "list_dialer_sessions_voice_dialer_sessions_get",
27
29
  "list_voice_dispositions_voice_dispositions_get",
30
+ "manual_dial_dialer_parallel_calls_manual_dial_post",
28
31
  "mint_softphone_token_dialer_sessions_token_get",
29
32
  "mute_call_dialer_sessions_calls_mute_post",
30
33
  "pause_dialer_session_dialer_sessions__session_id__pause_post",
31
34
  "read_queue_stats_dialer_sessions__session_id__queue_stats_get",
32
35
  "read_session_grid_dialer_sessions__session_id__grid_post",
36
+ "resume_dialer_session_voice_dialer_sessions__session_id__resume_post",
33
37
  "set_call_disposition_dialer_parallel_calls_calls__livekit_room_name__disposition_patch",
34
38
  "skip_contact_dialer_sessions__session_id__calls_skip_contact_post",
35
39
  "stop_dialer_session_dialer_sessions__session_id__stop_post"
@@ -135,6 +139,61 @@
135
139
  "purpose": "The whole rep screen wiring queue, softphone, outcome panel and transcript together.",
136
140
  "operations": [],
137
141
  "tiers": []
142
+ },
143
+ {
144
+ "name": "DialerScreen",
145
+ "file": "components/dialer-screen.tsx",
146
+ "purpose": "Finished calling page: pick a list, run a session, softphone, outcomes, transcript and history.",
147
+ "operations": [
148
+ "get_dialer_session_dialer_sessions__session_id__get",
149
+ "list_dialer_sessions_voice_dialer_sessions_get"
150
+ ],
151
+ "tiers": [
152
+ "read"
153
+ ]
154
+ },
155
+ {
156
+ "name": "CallLogScreen",
157
+ "file": "components/call-log-screen.tsx",
158
+ "purpose": "Finished call history page with outcome filter, recording, transcript and AI grade, and Call back on a call (CallContactButton, confirmed); also exports CallHistory.",
159
+ "operations": [
160
+ "list_calls_for_contact_voice_dialer_calls_get"
161
+ ],
162
+ "tiers": [
163
+ "read"
164
+ ]
165
+ },
166
+ {
167
+ "name": "DialerSessionDetail",
168
+ "file": "components/dialer-session-detail.tsx",
169
+ "purpose": "One dialing session as a full record: stats, details, calls and queue, with pause, confirmed resume and confirmed stop.",
170
+ "operations": [
171
+ "get_dialer_session_dialer_sessions__session_id__get",
172
+ "pause_dialer_session_dialer_sessions__session_id__pause_post",
173
+ "read_queue_stats_dialer_sessions__session_id__queue_stats_get",
174
+ "resume_dialer_session_voice_dialer_sessions__session_id__resume_post",
175
+ "stop_dialer_session_dialer_sessions__session_id__stop_post"
176
+ ],
177
+ "tiers": [
178
+ "read",
179
+ "write",
180
+ "external"
181
+ ]
182
+ },
183
+ {
184
+ "name": "CallContactButton",
185
+ "file": "components/call-contact-button.tsx",
186
+ "purpose": "Call one contact from any screen (contact page, table row): pick the number, a paused or completed session and the caller ID, turn on the softphone, confirm (external), and the softphone joins the call.",
187
+ "operations": [
188
+ "get_my_team_member_team_members_me_get",
189
+ "list_dialer_sessions_voice_dialer_sessions_get",
190
+ "list_dialer_numbers_voice_dialer_numbers_get",
191
+ "manual_dial_dialer_parallel_calls_manual_dial_post"
192
+ ],
193
+ "tiers": [
194
+ "read",
195
+ "external"
196
+ ]
138
197
  }
139
198
  ],
140
199
  "hooks": [
@@ -222,6 +281,7 @@
222
281
  }
223
282
  ],
224
283
  "scopes": [
284
+ "team:read",
225
285
  "voice:read",
226
286
  "voice:run",
227
287
  "voice:write"
@@ -251,10 +311,16 @@
251
311
  "decision": "Browser audio vendor for the Softphone",
252
312
  "default": "Twilio via lib/twilio-transport.ts; graph8's dialer runs on Twilio conferences. For another vendor write a SoftphoneTransport and delete twilio-transport.ts",
253
313
  "askHuman": true
314
+ },
315
+ {
316
+ "decision": "How a single contact is called outside a dialing session",
317
+ "default": "manual-dial under a PAUSED or COMPLETED session the rep picks (same call log and analytics), only once the softphone is ready; the rep is the personal key's user unless userId is passed",
318
+ "askHuman": false
254
319
  }
255
320
  ],
256
321
  "peerDependencies": {
257
322
  "react": ">=18",
258
- "@twilio/voice-sdk": "^2.18.5"
323
+ "@twilio/voice-sdk": "^2.18.5",
324
+ "lucide-react": ">=0.400.0"
259
325
  }
260
326
  }
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CallContactButton: call ONE contact from any screen (a contact page, a table row), through the same
5
+ * Twilio-backed dialer as CallQueue, with the rep's softphone joining the call.
6
+ *
7
+ * Why manual-dial (and not next-batch's force_next_contact): `force_next_contact` only picks a contact that is IN
8
+ * the session's list, and next-batch also dials the rest of the batch. `manual-dial` places exactly one call to the
9
+ * number you pass, attaches it to an existing session (same analytics and call log), and answers with the
10
+ * `conference_name` the softphone joins. It needs a PAUSED or COMPLETED session (an idle ACTIVE one is re-paused by
11
+ * voice; one with live calls is refused) and refuses DRAFT, so the picker offers paused and completed sessions.
12
+ *
13
+ * SDK operations, in order:
14
+ * 0. GET /team-members/me (teamMembers.getMyTeamMember) read - only without `userId`: the rep is the person who
15
+ * minted a PERSONAL key (`propelauth_user_id`, `email`). An organisation key is refused there (403), so the
16
+ * panel then asks for `userId`: an org key is not a rep.
17
+ * 1. GET /voice/dialer/sessions?status=PAUSED|COMPLETED (voice.listDialerSessions) read - the session to log under
18
+ * 2. GET /voice/dialer/numbers (voice.listDialerNumbers) read - caller ID
19
+ * 3. GET /dialer/sessions/token?user_id= (Softphone, EXTERNAL, confirmed inside Softphone)
20
+ * 4. POST /dialer/parallel-calls/manual-dial (dialer.manualDial) EXTERNAL -> ConfirmSpendDialog
21
+ * CALLS A REAL PERSON and spends dialer credits. An unknown number is matched to a contact (or one is created).
22
+ * The response's `conference_name` goes to the Softphone, which joins with audio.
23
+ *
24
+ * The call is only offered once the softphone is ready, so the rep is never late to their own call (the response
25
+ * is the first moment the conference name is known; a softphone that registers after it would join late).
26
+ */
27
+ import { useEffect, useState } from "react";
28
+ import { Phone } from "lucide-react";
29
+ import { Button } from "@graph8/sdk/ui";
30
+ import { ApiError } from "@/g8/components/api-error";
31
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
32
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
33
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
34
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
35
+ import { useDialerNumbers } from "../hooks/use-dialer-numbers";
36
+ import { dataOf } from "../lib/dialer-types";
37
+ import type { SoftphoneTransport } from "../lib/softphone-transport";
38
+ import { Softphone, type SoftphoneState } from "./softphone";
39
+
40
+ export interface CallableContact {
41
+ id?: string | number | null;
42
+ first_name?: string | null;
43
+ last_name?: string | null;
44
+ full_name?: string | null;
45
+ name?: string | null;
46
+ email?: string | null;
47
+ company?: string | null;
48
+ company_name?: string | null;
49
+ phone?: string | null;
50
+ mobile_phone?: string | null;
51
+ direct_phone?: string | null;
52
+ work_phone?: string | null;
53
+ }
54
+
55
+ export interface CallContactButtonProps {
56
+ contact: CallableContact;
57
+ /** The rep's user id: the softphone credential and the call are minted for it. Without it the button explains. */
58
+ userId?: string;
59
+ /** The rep's email. Only used together with `userId`; without `userId` both come from the personal key's user. */
60
+ userEmail?: string;
61
+ /** Log the call under this session (PAUSED or COMPLETED). Omit to pick one. */
62
+ sessionId?: string;
63
+ /** Caller ID. Defaults to the session's number, else the first dialer number. */
64
+ fromPhone?: string;
65
+ /** Your audio client (`createTwilioTransport()`); by default the Twilio adapter loads on demand. */
66
+ transport?: SoftphoneTransport;
67
+ label?: string;
68
+ onCalled?: (result: ManualDialResult) => void;
69
+ className?: string;
70
+ }
71
+
72
+ export interface ManualDialResult {
73
+ session_id?: string;
74
+ status?: string;
75
+ conference_name?: string | null;
76
+ calls?: Array<{ call_id?: string; to_phone?: string; status?: string }>;
77
+ }
78
+
79
+ interface SessionOption {
80
+ session_id: string;
81
+ name?: string | null;
82
+ status?: string | null;
83
+ from_phone?: string | null;
84
+ }
85
+
86
+ const CALLABLE = ["PAUSED", "COMPLETED"];
87
+
88
+ /** Every distinct phone on the contact, labelled by field. */
89
+ export function contactPhones(c: CallableContact): Array<{ field: string; phone: string }> {
90
+ const out: Array<{ field: string; phone: string }> = [];
91
+ for (const field of ["mobile_phone", "direct_phone", "work_phone", "phone"] as const) {
92
+ const phone = (c[field] ?? "").trim();
93
+ // A locked field arrives masked as "***" (search results before saving): never offer it as a number.
94
+ if (phone && !phone.includes("*") && !out.some((p) => p.phone === phone)) out.push({ field, phone });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function displayName(c: CallableContact): string {
100
+ return c.full_name || c.name || [c.first_name, c.last_name].filter(Boolean).join(" ") || c.email || "this contact";
101
+ }
102
+
103
+ export function CallContactButton(props: CallContactButtonProps) {
104
+ const { contact, label = "Call", className } = props;
105
+ const [open, setOpen] = useState(false);
106
+ const phones = contactPhones(contact);
107
+ if (open) return <CallContactPanel {...props} onClose={() => setOpen(false)} />;
108
+ const name = displayName(contact);
109
+ // Closed: nothing is loaded, so a table of rows with this button makes no requests until one is clicked.
110
+ return (
111
+ <Button size="sm" variant="outline" className={className} disabled={phones.length === 0} title={phones.length === 0 ? `${name} has no phone number` : undefined} onClick={() => setOpen(true)}>
112
+ <Phone className="mr-1 h-4 w-4" aria-hidden /> {label}
113
+ </Button>
114
+ );
115
+ }
116
+
117
+ function CallContactPanel(props: CallContactButtonProps & { onClose: () => void }) {
118
+ const { contact, sessionId, fromPhone, transport, onCalled, onClose, className } = props;
119
+ const me = useG8Query(
120
+ async (signal) => dataOf<{ propelauth_user_id?: string | null; email?: string | null }>(await g8Get("/team-members/me", undefined, { signal })),
121
+ [],
122
+ { enabled: !props.userId },
123
+ );
124
+ const userId = props.userId ?? me.data?.propelauth_user_id ?? undefined;
125
+ // user_id and user_email must describe the same person: a userEmail without a userId (e.g. a history filtered to
126
+ // another rep) is ignored, and both come from /team-members/me.
127
+ const userEmail = props.userId ? props.userEmail : (me.data?.email ?? undefined);
128
+ const phones = contactPhones(contact);
129
+ const [phone, setPhone] = useState(phones[0]?.phone ?? "");
130
+ const [session, setSession] = useState(sessionId ?? "");
131
+ const [caller, setCaller] = useState(fromPhone ?? "");
132
+ const [softphone, setSoftphone] = useState<SoftphoneState>("off");
133
+ const [confirming, setConfirming] = useState(false);
134
+ const [conference, setConference] = useState<string | null>(null);
135
+ const audio = useLazyTransport(transport, true);
136
+
137
+ const sessions = useG8Query(
138
+ async (signal) => {
139
+ const lists = await Promise.all(
140
+ CALLABLE.map(async (status) => dataOf<{ sessions?: SessionOption[] }>(await g8Get("/voice/dialer/sessions", { status, page: 1, page_size: 25, user_email: userEmail }, { signal })).sessions ?? []),
141
+ );
142
+ return lists.flat();
143
+ },
144
+ [userEmail],
145
+ { enabled: !sessionId },
146
+ );
147
+ const numbers = useDialerNumbers();
148
+ const chosenSession = (sessions.data ?? []).find((s) => s.session_id === session);
149
+ const from = caller || chosenSession?.from_phone || numbers.numbers[0]?.phone_number || "";
150
+
151
+ const dial = useG8Mutation(async () => {
152
+ const env = await g8Post<ManualDialResult>("/dialer/parallel-calls/manual-dial", {
153
+ session_id: session,
154
+ user_id: userId,
155
+ user_email: userEmail,
156
+ to_phone: phone,
157
+ from_phone: from,
158
+ first_name: contact.first_name ?? undefined,
159
+ last_name: contact.last_name ?? undefined,
160
+ company: contact.company ?? contact.company_name ?? undefined,
161
+ contact_id: contact.id != null ? String(contact.id) : undefined,
162
+ email: contact.email ?? undefined,
163
+ });
164
+ return dataOf<ManualDialResult>(env);
165
+ }, { invalidates: ["dialer"] });
166
+
167
+ const confirm = async () => {
168
+ try {
169
+ const result = await dial.mutateAsync(undefined);
170
+ setConference(result.conference_name ?? null);
171
+ onCalled?.(result);
172
+ } catch {
173
+ // ApiError below shows why; nothing was dialed on a refusal.
174
+ } finally {
175
+ setConfirming(false);
176
+ }
177
+ };
178
+
179
+ const name = displayName(contact);
180
+ const missing = !userId
181
+ ? me.loading
182
+ ? "Finding your user..."
183
+ : "Calling from the browser needs the rep: pass `userId`, or use a personal API key (an organisation key is not a rep)."
184
+ : phones.length === 0
185
+ ? `${name} has no phone number.`
186
+ : !session
187
+ ? "Pick a paused or completed dialer session to log the call under."
188
+ : !from
189
+ ? "Pick the number to call from."
190
+ : softphone !== "ready" && softphone !== "in-call"
191
+ ? "Turn on the softphone first, so you are on the line when they answer."
192
+ : null;
193
+
194
+ const select = "h-9 w-full rounded-md border border-border bg-background px-2 text-sm";
195
+ return (
196
+ <div className={`space-y-3 rounded-lg border border-border p-3 text-sm ${className ?? ""}`} aria-label={`Call ${name}`}>
197
+ <div className="flex items-center justify-between">
198
+ <p className="font-medium">Call {name}</p>
199
+ <Button size="sm" variant="ghost" onClick={onClose}>Close</Button>
200
+ </div>
201
+ <select aria-label="Number to call" className={select} value={phone} onChange={(e) => setPhone(e.target.value)}>
202
+ {phones.map((p) => <option key={p.phone} value={p.phone}>{p.phone} ({p.field.replace("_", " ")})</option>)}
203
+ </select>
204
+ {!sessionId ? (
205
+ <select aria-label="Dialer session" className={select} value={session} onChange={(e) => setSession(e.target.value)}>
206
+ <option value="">{sessions.loading ? "Loading sessions..." : "Log the call under session..."}</option>
207
+ {(sessions.data ?? []).map((s) => <option key={s.session_id} value={s.session_id}>{s.name || s.session_id} ({(s.status ?? "").toLowerCase()})</option>)}
208
+ </select>
209
+ ) : null}
210
+ {!sessionId && sessions.data?.length === 0 ? (
211
+ <p className="text-muted-foreground">No paused or completed session yet. Start one on the Dialer screen and pause it; the call is logged under it.</p>
212
+ ) : null}
213
+ <select aria-label="Call from" className={select} value={from} onChange={(e) => setCaller(e.target.value)}>
214
+ {numbers.numbers.map((n) => <option key={n.phone_number} value={n.phone_number}>{n.friendly_name || n.phone_number}</option>)}
215
+ {from && !numbers.numbers.some((n) => n.phone_number === from) ? <option value={from}>{from}</option> : null}
216
+ </select>
217
+ {userId && audio ? (
218
+ <Softphone userId={userId} transport={audio} conferenceName={conference} onStateChange={setSoftphone} />
219
+ ) : null}
220
+ {sessions.error || numbers.error ? <ApiError error={sessions.error ?? numbers.error} title="Could not load the dialer" onRetry={() => { void sessions.refetch(); void numbers.refetch(); }} /> : null}
221
+ {dial.error ? <ApiError error={dial.error} title="The call was not placed" /> : null}
222
+ {conference ? <p role="status">Calling {name} at {phone}. You join the call through the softphone.</p> : null}
223
+ {missing ? <p className="text-muted-foreground">{missing}</p> : null}
224
+ <Button disabled={Boolean(missing) || dial.loading} onClick={() => setConfirming(true)}>
225
+ <Phone className="mr-1 h-4 w-4" aria-hidden /> Call {phone}
226
+ </Button>
227
+ <ConfirmSpendDialog
228
+ open={confirming}
229
+ tier="external"
230
+ title={`Call ${name}?`}
231
+ description={`This calls ${phone} now from ${from}, through your softphone, and spends dialer credits per attempt and per connected minute.`}
232
+ recipients={1}
233
+ confirmLabel="Call now"
234
+ confirming={dial.loading}
235
+ onConfirm={() => void confirm()}
236
+ onCancel={() => setConfirming(false)}
237
+ />
238
+ </div>
239
+ );
240
+ }
241
+
242
+ /** The caller's transport, else the Twilio adapter loaded on demand once the panel opens. */
243
+ function useLazyTransport(provided: SoftphoneTransport | undefined, active: boolean): SoftphoneTransport | null {
244
+ const [loaded, setLoaded] = useState<SoftphoneTransport | null>(null);
245
+ useEffect(() => {
246
+ if (!active || provided || loaded) return;
247
+ let live = true;
248
+ import("../lib/twilio-transport").then((m) => { if (live) setLoaded(m.createTwilioTransport()); }).catch(() => undefined);
249
+ return () => { live = false; };
250
+ }, [active, provided, loaded]);
251
+ return provided ?? loaded;
252
+ }