@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
@@ -0,0 +1,319 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DialerScreen: the whole calling page for a rep. Tabs: Session (pick a list, create a session, dial batch by batch,
5
+ * watch live calls, take them in the browser, record outcomes, read the transcript), Sessions (every session as a list;
6
+ * a row opens the full session record with stats, calls, queue, pause / resume / stop; ?tab=sessions&open=<id>&view=full
7
+ * is the full page) and History (the rep's past calls).
8
+ *
9
+ * Flow: choose a list (ListPicker) -> SessionStartForm (caller ID, power or parallel mode; creating it dials nobody)
10
+ * -> CallQueue ("Dial next batch" confirms each batch; pause, stop with confirm, skip contacts, hang up, mute, voicemail
11
+ * drop with confirm) -> Softphone ("Enable calling" confirms the one-hour credential) -> DispositionPanel for the call
12
+ * that just finished -> TranscriptViewer. Resume a session with `?session=<id>`.
13
+ *
14
+ * SDK operations (all through the composed parts): see CallQueue, SessionStartForm, Softphone, DispositionPanel, CallLog.
15
+ * External and destructive steps always open ConfirmSpendDialog; nothing dials by itself and there is no auto-advance.
16
+ * The browser audio vendor is `transport`; by default the Twilio adapter loads on demand (needs @twilio/voice-sdk).
17
+ * Buying or verifying a caller ID number happens in the graph8 app.
18
+ */
19
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
20
+ import { Phone } from "lucide-react";
21
+ import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
22
+ import { ApiError } from "@/g8/components/api-error";
23
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
24
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
25
+ import { g8Get } from "@/g8/lib/g8-client";
26
+ import { EmptyState } from "@/g8/components/empty-state";
27
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
28
+ import { SectionCard } from "@/g8/components/screen/section-card";
29
+ import { useUrlState } from "@/g8/hooks/use-url-state";
30
+ import { ListPicker, type ListSummary } from "@/g8/lists/components/list-picker";
31
+ import { SplitView } from "@/g8/components/screen/split-view";
32
+ import { StatusPill } from "@/g8/components/screen/status-pill";
33
+ import { Toolbar } from "@/g8/components/screen/toolbar";
34
+ import { DialerSessionDetail } from "./dialer-session-detail";
35
+ import { dataOf, isLiveCall, normalizeStatus, needsDisposition, type LiveCall, type NextBatchResult, type SessionDetail } from "../lib/dialer-types";
36
+ import type { SoftphoneTransport } from "../lib/softphone-transport";
37
+ import { CallHistory } from "./call-log-screen";
38
+ import { CallQueue } from "./call-queue";
39
+ import { DispositionPanel } from "./disposition-panel";
40
+ import { Softphone } from "./softphone";
41
+ import { TranscriptViewer } from "./transcript-viewer";
42
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
43
+ import { RecordLink } from "@/g8/components/screen/record-link";
44
+
45
+ export interface DialerScreenProps {
46
+ /** The rep these calls belong to. When omitted the start form asks for the graph8 user id. */
47
+ userId?: string;
48
+ userEmail?: string;
49
+ /** Skip the list picker. */
50
+ listId?: string;
51
+ listTitle?: string;
52
+ /** Resume this session. */
53
+ sessionId?: string;
54
+ /** Your audio client. Default: the Twilio adapter, loaded on demand. */
55
+ transport?: SoftphoneTransport;
56
+ toolbarActions?: ReactNode;
57
+ renderRowActions?: (call: LiveCall) => ReactNode;
58
+ renderDetailActions?: (call: LiveCall) => ReactNode;
59
+ extraDetailTabs?: Array<{ id: string; label: string; render: (call: LiveCall) => ReactNode }>;
60
+ className?: string;
61
+ }
62
+
63
+ const TABS = [
64
+ { id: "session", label: "Session" },
65
+ { id: "sessions", label: "Sessions" },
66
+ { id: "history", label: "History" },
67
+ ];
68
+
69
+ interface SessionRow {
70
+ session_id: string;
71
+ name?: string | null;
72
+ status?: string | null;
73
+ total_calls?: number | null;
74
+ user_email?: string | null;
75
+ from_phone?: string | null;
76
+ created_at?: unknown;
77
+ session_metadata?: Record<string, unknown> | null;
78
+ }
79
+
80
+ const STATUSES = ["ACTIVE", "PAUSED", "COMPLETED"];
81
+
82
+ function useDebounced<T>(value: T, ms = 300): T {
83
+ const [v, setV] = useState(value);
84
+ useEffect(() => {
85
+ const t = setTimeout(() => setV(value), ms);
86
+ return () => clearTimeout(t);
87
+ }, [value, ms]);
88
+ return v;
89
+ }
90
+
91
+ function sessionTone(status?: string | null) {
92
+ const s = normalizeStatus(status);
93
+ return s === "active" ? "positive" : s === "paused" ? "warning" : "neutral";
94
+ }
95
+
96
+ function created(v: unknown): string {
97
+ const d = typeof v === "string" ? new Date(v) : null;
98
+ return d && !Number.isNaN(d.getTime()) ? d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "";
99
+ }
100
+
101
+ /** All dialing sessions (GET /voice/dialer/sessions). A row opens the full session record: ?tab=sessions&open=<id>[&view=full]. */
102
+ function SessionsTab({ userEmail, onWork }: { userEmail?: string; onWork: (id: string) => void }) {
103
+ // Its own param: `session` belongs to the Session tab (the running session); closing a detail here must never clear it.
104
+ const [openId, setOpenId] = useUrlState("open");
105
+ const [view, setView] = useUrlState("view");
106
+ const [page, setPage] = useState(1);
107
+ const [status, setStatus] = useState("");
108
+ const [name, setName] = useState("");
109
+ const search = useDebounced(name.trim());
110
+ const q = useG8Query(
111
+ async (signal) => {
112
+ const env = await g8Get("/voice/dialer/sessions", { page, page_size: 25, status: status || undefined, name: search || undefined, user_email: userEmail }, { signal });
113
+ const d = dataOf<{ sessions?: SessionRow[]; pagination?: { total_items?: number; total_pages?: number } }>(env);
114
+ return { rows: d.sessions ?? [], total: d.pagination?.total_items, pages: d.pagination?.total_pages };
115
+ },
116
+ [page, status, search, userEmail],
117
+ );
118
+ const rows = q.data?.rows ?? [];
119
+ const reset = () => setPage(1);
120
+ const list = (
121
+ <div className="space-y-3">
122
+ <Toolbar
123
+ search={{ value: name, onChange: (v) => { setName(v); reset(); }, placeholder: "Search by session name" }}
124
+ filters={
125
+ <select aria-label="Session status" className="h-8 rounded-md border border-border bg-background px-2 text-sm" value={status} onChange={(e) => { setStatus(e.target.value); reset(); }}>
126
+ <option value="">Any status</option>
127
+ {STATUSES.map((s) => <option key={s} value={s}>{s.toLowerCase()}</option>)}
128
+ </select>
129
+ }
130
+ />
131
+ {q.error ? (
132
+ <ApiError error={q.error} title="Could not load sessions" onRetry={() => void q.refetch()} />
133
+ ) : q.loading && !q.data ? (
134
+ <Skeleton className="h-32 w-full" aria-label="Loading sessions" />
135
+ ) : rows.length === 0 ? (
136
+ <EmptyState icon={<Phone className="size-6" />} title={name || status ? "No sessions match" : "No sessions yet"} description={name || status ? "Try a different search or status." : "Start one in the Session tab. Each session keeps its queue, calls and outcomes."} />
137
+ ) : (
138
+ <Table aria-label="Dialing sessions">
139
+ <TableHeader><TableRow><TableHead>Session</TableHead><TableHead>Status</TableHead><TableHead>Calls</TableHead><TableHead>Rep</TableHead><TableHead>Started</TableHead></TableRow></TableHeader>
140
+ <TableBody>
141
+ {rows.map((r) => (
142
+ <TableRow key={r.session_id} data-state={openId === r.session_id ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => setOpenId(r.session_id))}>
143
+ <TableCell className="font-medium"><RecordLink param="open" id={r.session_id} onOpen={() => setOpenId(r.session_id)}>{r.name ?? "Dialing session"}</RecordLink></TableCell>
144
+ <TableCell><StatusPill tone={sessionTone(r.status)}>{normalizeStatus(r.status) || "unknown"}</StatusPill></TableCell>
145
+ <TableCell className="tabular-nums">{r.total_calls ?? ""}</TableCell>
146
+ <TableCell>{r.user_email ?? ""}</TableCell>
147
+ <TableCell>{created(r.created_at)}</TableCell>
148
+ </TableRow>
149
+ ))}
150
+ </TableBody>
151
+ </Table>
152
+ )}
153
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
154
+ <span>{q.data?.total != null ? `${q.data.total.toLocaleString()} sessions` : `Page ${page}`}</span>
155
+ <div className="flex gap-2">
156
+ <Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage(page - 1)}>Previous</Button>
157
+ <Button size="sm" variant="outline" disabled={q.data?.pages != null ? page >= q.data.pages : rows.length < 25} onClick={() => setPage(page + 1)}>Next</Button>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ );
162
+ const expanded = view === "full" && Boolean(openId);
163
+ return (
164
+ <SplitView
165
+ list={list}
166
+ detailOpen={Boolean(openId)}
167
+ onCloseDetail={() => { setOpenId(null); setView(null); }}
168
+ expanded={expanded}
169
+ onExpandedChange={(next) => setView(next ? "full" : null)}
170
+ detail={openId ? (
171
+ <DialerSessionDetail
172
+ key={openId}
173
+ sessionId={openId}
174
+ userEmail={userEmail}
175
+ onWork={onWork}
176
+ onClose={() => { setOpenId(null); setView(null); }}
177
+ onExpand={expanded ? undefined : () => setView("full")}
178
+ onCollapse={expanded ? () => setView(null) : undefined}
179
+ onChanged={() => void q.refetch()}
180
+ />
181
+ ) : null}
182
+ />
183
+ );
184
+ }
185
+
186
+ /** The Twilio adapter, created once in the browser; null until loaded or when the optional peer is missing. */
187
+ function useDefaultTransport(provided?: SoftphoneTransport): SoftphoneTransport | null {
188
+ const [loaded, setLoaded] = useState<SoftphoneTransport | null>(null);
189
+ useEffect(() => {
190
+ if (provided) return;
191
+ let live = true;
192
+ import("../lib/twilio-transport").then((m) => { if (live) setLoaded(m.createTwilioTransport()); }).catch(() => undefined);
193
+ return () => { live = false; };
194
+ }, [provided]);
195
+ return provided ?? loaded;
196
+ }
197
+
198
+ /** Every field of the session in one card; re-read when the call list changes. */
199
+ function SessionDetails({ sessionId, version }: { sessionId: string; version: number }) {
200
+ const q = useG8Query(async (signal) => dataOf<SessionDetail>(await g8Get(`/dialer/sessions/${encodeURIComponent(sessionId)}`, undefined, { signal })), [sessionId, version]);
201
+ const d = q.data;
202
+ if (q.error) return <ApiError error={q.error} title="Could not load the session" onRetry={() => void q.refetch()} />;
203
+ if (!d) return <Skeleton className="h-24 w-full" aria-label="Loading session details" />;
204
+ const dash = "";
205
+ return (
206
+ <SectionCard title="Session details">
207
+ <KeyValueList
208
+ columns={1}
209
+ items={[
210
+ { label: "Name", value: d.name ?? dash },
211
+ { label: "Status", value: String(d.status ?? dash).toLowerCase() },
212
+ { label: "List", value: d.list_title ?? d.list_id ?? dash },
213
+ { label: "Mode", value: d.dialing_mode ? `${d.dialing_mode}${d.dialing_mode === "parallel" ? ` (${Math.min(4, Math.max(1, d.parallel_count ?? 2))} at once)` : ""}` : dash },
214
+ { label: "Caller ID", value: d.from_phone ?? dash },
215
+ { label: "Contacts", value: d.counts?.total ?? dash },
216
+ { label: "Dialed", value: d.counts?.total_dialed ?? dash },
217
+ { label: "Left in queue", value: d.counts?.queue_remaining ?? dash },
218
+ { label: "Voicemails", value: d.live_state?.voicemail_calls ?? dash },
219
+ ]}
220
+ />
221
+ </SectionCard>
222
+ );
223
+ }
224
+
225
+ function SessionTab(props: DialerScreenProps & { onCalls: (calls: LiveCall[]) => void }) {
226
+ const [sessionId, setSessionId] = useUrlState("session", props.sessionId ?? "");
227
+ const [list, setList] = useState<{ id?: string; title?: string } | null>(props.listId ? { id: props.listId, title: props.listTitle } : null);
228
+ const [calls, setCalls] = useState<LiveCall[]>([]);
229
+ const [selected, setSelected] = useState<LiveCall | null>(null);
230
+ const [batch, setBatch] = useState<NextBatchResult | null>(null);
231
+ const transport = useDefaultTransport(props.transport);
232
+ const waiting = useMemo(() => calls.find(needsDisposition) ?? null, [calls]);
233
+ const focus = selected ?? waiting;
234
+ const { onCalls } = props;
235
+ useEffect(() => { onCalls(calls); }, [calls, onCalls]);
236
+
237
+ if (!sessionId && !list) {
238
+ return (
239
+ <SectionCard title="Who do you want to call?" description="Pick a contact list. Creating a session dials nobody; each batch is confirmed before it goes out.">
240
+ <ListPicker type="contacts" allowCreate={false} onChange={(l: ListSummary) => setList({ id: String(l.id), title: l.title })} />
241
+ </SectionCard>
242
+ );
243
+ }
244
+ return (
245
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
246
+ <CallQueue
247
+ sessionId={sessionId || undefined}
248
+ userId={props.userId}
249
+ userEmail={props.userEmail}
250
+ listId={list?.id}
251
+ listTitle={list?.title}
252
+ onSessionCreated={setSessionId}
253
+ onBatch={setBatch}
254
+ onCalls={setCalls}
255
+ onSelectCall={setSelected}
256
+ />
257
+ <div className="space-y-4">
258
+ {props.userId && transport ? (
259
+ <Softphone userId={props.userId} transport={transport} conferenceName={batch?.conference_name} joinMuted={(batch?.batch?.length ?? 1) > 1} />
260
+ ) : props.userId ? (
261
+ <Skeleton className="h-24 w-full" aria-label="Loading softphone" />
262
+ ) : (
263
+ <SectionCard title="Softphone"><p className="text-sm text-muted-foreground">Pass the rep's user id to take calls in this browser.</p></SectionCard>
264
+ )}
265
+ {sessionId ? (
266
+ <>
267
+ <SessionDetails sessionId={sessionId} version={calls.length} />
268
+ <DispositionPanel key={`outcome-${focus?.call_id}`} call={focus} onSaved={() => setSelected(null)} />
269
+ {focus ? (
270
+ <>
271
+ <TranscriptViewer key={`transcript-${focus.call_id}`} roomName={focus.room_name ?? focus.call_id} live={isLiveCall(focus)} />
272
+ {props.renderDetailActions ? <div className="flex flex-wrap gap-2">{props.renderDetailActions(focus)}</div> : null}
273
+ {(props.extraDetailTabs ?? []).map((t) => <SectionCard key={t.id} title={t.label}>{t.render(focus)}</SectionCard>)}
274
+ </>
275
+ ) : (
276
+ <EmptyState title="Nothing on screen" description="The transcript of the call you select shows here." />
277
+ )}
278
+ </>
279
+ ) : null}
280
+ </div>
281
+ </div>
282
+ );
283
+ }
284
+
285
+ export function DialerScreen(props: DialerScreenProps) {
286
+ const [tab, setTab] = useUrlState("tab", "session");
287
+ const [, setView] = useUrlState("view");
288
+ const [, setSession] = useUrlState("session");
289
+ const [calls, setCalls] = useState<LiveCall[]>([]);
290
+ const active = TABS.some((t) => t.id === tab) ? tab : "session";
291
+ // The header counters describe the Session tab's calls; drop them when it unmounts so they are never stale.
292
+ useEffect(() => { if (active !== "session") setCalls([]); }, [active]);
293
+ const live = calls.filter(isLiveCall).length;
294
+ const stats = calls.length > 0
295
+ ? [
296
+ { label: "On calls now", value: live, tone: live > 0 ? ("positive" as const) : ("neutral" as const) },
297
+ { label: "Need an outcome", value: calls.filter(needsDisposition).length, tone: calls.some(needsDisposition) ? ("warning" as const) : ("neutral" as const) },
298
+ { label: "Voicemails", value: calls.filter((c) => c.is_voicemail).length },
299
+ { label: "Calls this session", value: calls.length },
300
+ ]
301
+ : undefined;
302
+ return (
303
+ <ScreenLayout
304
+ className={props.className}
305
+ title="Dialer"
306
+ description="Call a list batch by batch, take calls in the browser and record every outcome."
307
+ icon={<Phone className="size-5" />}
308
+ actions={props.toolbarActions}
309
+ tabs={TABS}
310
+ activeTab={active}
311
+ onTabChange={(id) => setTab(id)}
312
+ stats={stats}
313
+ >
314
+ {active === "session" ? <SessionTab {...props} onCalls={setCalls} /> : null}
315
+ {active === "sessions" ? <SessionsTab userEmail={props.userEmail} onWork={(id) => { setSession(id); setView(null); setTab("session"); }} /> : null}
316
+ {active === "history" ? <CallHistory userEmail={props.userEmail} /> : null}
317
+ </ScreenLayout>
318
+ );
319
+ }
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DialerSessionDetail: one dialer session as a complete record. Tabs: Overview (stats and every session field), Calls (the
5
+ * calls in the session with hang up, mute and confirmed voicemail drop) and Queue (contacts still waiting, with skip).
6
+ * Header actions: Pause, Resume (dials the next contacts, so it is confirmed), Stop (hangs up live calls, confirmed) and
7
+ * "Work this session" (hands the id to the caller so the calling workspace can take over).
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. GET /dialer/sessions/{id}/queue-stats + GET /dialer/sessions/{id} read (polled every 5s via useDialerSession)
11
+ * 2. POST /dialer/sessions/{id}/pause write (hangs up live calls)
12
+ * 3. POST /voice/dialer/sessions/{id}/resume { max_contacts } EXTERNAL -> ConfirmSpendDialog first (places real calls)
13
+ * 4. POST /dialer/sessions/{id}/stop write -> ConfirmSpendDialog (one-way, hangs up live calls)
14
+ * Calls and queue use LiveCallsTable and WaitingContacts (see those files for their operations and confirmations).
15
+ * Full-page mode: pass `expanded` plus `onExpand` / `onCollapse` (DialerScreen ties them to `?view=full`).
16
+ */
17
+ import { useState, type ReactNode } from "react";
18
+ import { Phone } from "lucide-react";
19
+ import { Button, Skeleton } from "@graph8/sdk/ui";
20
+ import { ApiError } from "@/g8/components/api-error";
21
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
22
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
23
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
24
+ import { SectionCard } from "@/g8/components/screen/section-card";
25
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
26
+ import { StatusPill } from "@/g8/components/screen/status-pill";
27
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
28
+ import { g8Post } from "@/g8/lib/g8-client";
29
+ import { useDialerSession } from "../hooks/use-dialer-session";
30
+ import { isLiveCall, isSessionOver, needsDisposition, normalizeStatus } from "../lib/dialer-types";
31
+ import { WaitingContacts } from "./call-queue";
32
+ import { LiveCallsTable } from "./live-calls-table";
33
+ import { pluralWord } from "@/g8/lib/plural";
34
+
35
+ export interface DialerSessionDetailProps {
36
+ sessionId: string;
37
+ /** Needed by pause on some orgs; optional. */
38
+ userEmail?: string;
39
+ /** Show "Work this session" and call this with the id. */
40
+ onWork?: (sessionId: string) => void;
41
+ onClose?: () => void;
42
+ onExpand?: () => void;
43
+ onCollapse?: () => void;
44
+ /** Called after pause, resume or stop succeeded (refresh your list). */
45
+ onChanged?: () => void;
46
+ /** Extra buttons in the header. */
47
+ actions?: ReactNode;
48
+ className?: string;
49
+ }
50
+
51
+ function tone(status: string) {
52
+ return status === "active" ? "positive" : status === "paused" ? "warning" : status === "completed" || status === "stopped" ? "neutral" : "info";
53
+ }
54
+
55
+ export function DialerSessionDetail({ sessionId, userEmail, onWork, onClose, onExpand, onCollapse, onChanged, actions, className }: DialerSessionDetailProps) {
56
+ const session = useDialerSession(sessionId);
57
+ const [version, setVersion] = useState(0);
58
+ const [asking, setAsking] = useState<"resume" | "stop" | null>(null);
59
+ const changed = () => { setVersion((v) => v + 1); session.refresh(); onChanged?.(); };
60
+ const base = `/dialer/sessions/${encodeURIComponent(sessionId)}`;
61
+ const pause = useG8Mutation(() => g8Post(`${base}/pause`, undefined, { query: { user_email: userEmail } }), { onSuccess: changed });
62
+ const stop = useG8Mutation(() => g8Post(`${base}/stop`), { onSuccess: () => { setAsking(null); changed(); }, onError: () => setAsking(null) });
63
+ const detail = session.detail;
64
+ const mode = detail?.dialing_mode ?? "power";
65
+ const count = mode === "parallel" ? Math.min(4, Math.max(1, detail?.parallel_count ?? 2)) : 1;
66
+ const resume = useG8Mutation(() => g8Post(`/voice/dialer/sessions/${encodeURIComponent(sessionId)}/resume`, { max_contacts: count }), { onSuccess: () => { setAsking(null); changed(); }, onError: () => setAsking(null) });
67
+
68
+ if (!session.stats && !session.error) return <Skeleton className="h-48 w-full" aria-label="Loading session" />;
69
+ const status = normalizeStatus(session.stats?.status ?? detail?.status);
70
+ const over = isSessionOver(status);
71
+ const calls = session.calls;
72
+ const failure = session.error ?? pause.error ?? stop.error ?? resume.error;
73
+
74
+ const header = (
75
+ <>
76
+ {onWork && !over ? <Button size="sm" variant="outline" onClick={() => onWork(sessionId)}><Phone className="mr-1 size-4" />Work this session</Button> : null}
77
+ {status === "active" ? <Button size="sm" variant="outline" disabled={pause.loading} loading={pause.loading} onClick={() => void pause.mutate(undefined)}>Pause</Button> : null}
78
+ {status === "paused" ? <Button size="sm" variant="outline" onClick={() => setAsking("resume")}>Resume</Button> : null}
79
+ {!over ? <Button size="sm" variant="outline" onClick={() => setAsking("stop")}>Stop</Button> : null}
80
+ {actions}
81
+ </>
82
+ );
83
+ const overview = (
84
+ <div className="space-y-4">
85
+ <StatStrip
86
+ items={[
87
+ { label: "Left in queue", value: session.stats?.queue_remaining ?? detail?.counts?.queue_remaining ?? 0 },
88
+ { label: "On calls now", value: calls.filter(isLiveCall).length, tone: calls.some(isLiveCall) ? "positive" : "neutral" },
89
+ { label: "Need an outcome", value: calls.filter(needsDisposition).length, tone: calls.some(needsDisposition) ? "warning" : "neutral" },
90
+ { label: "Voicemails", value: detail?.live_state?.voicemail_calls ?? calls.filter((c) => c.is_voicemail).length },
91
+ ]}
92
+ />
93
+ <SectionCard title="Session details">
94
+ <KeyValueList
95
+ columns={2}
96
+ items={[
97
+ { label: "Name", value: detail?.name ?? "" },
98
+ { label: "Status", value: status },
99
+ { label: "List", value: detail?.list_title ?? detail?.list_id ?? "" },
100
+ { label: "Mode", value: detail?.dialing_mode ? `${detail.dialing_mode}${detail.dialing_mode === "parallel" ? ` (${count} at once)` : ""}` : "" },
101
+ { label: "Caller ID", value: detail?.from_phone ?? "" },
102
+ { label: "Contacts", value: detail?.counts?.total },
103
+ { label: "Dialed", value: detail?.counts?.total_dialed },
104
+ { label: "Not dialed yet", value: detail?.counts?.total_undialed },
105
+ { label: "Session id", value: sessionId, mono: true, wide: true },
106
+ ]}
107
+ />
108
+ </SectionCard>
109
+ </div>
110
+ );
111
+
112
+ return (
113
+ <div className={className}>
114
+ {failure ? <ApiError error={failure} title="Session problem" onRetry={() => session.refresh()} /> : null}
115
+ <DetailPanel
116
+ title={detail?.name ?? "Dialing session"}
117
+ subtitle={detail?.list_title ?? undefined}
118
+ badges={<StatusPill tone={tone(status)} dot>{status || "unknown"}</StatusPill>}
119
+ actions={header}
120
+ onClose={onClose}
121
+ onExpand={onExpand}
122
+ onCollapse={onCollapse}
123
+ tabs={[
124
+ { id: "overview", label: "Overview", content: overview },
125
+ { id: "calls", label: "Calls", count: calls.length, content: <LiveCallsTable sessionId={sessionId} calls={calls} onChanged={changed} /> },
126
+ { id: "queue", label: "Queue", content: <WaitingContacts sessionId={sessionId} version={version} onSkipped={changed} /> },
127
+ ]}
128
+ />
129
+ <ConfirmSpendDialog
130
+ open={asking === "resume"}
131
+ tier="external"
132
+ title={count > 1 ? `Resume and dial the next ${count} ${pluralWord(count, "contact")}?` : "Resume and dial the next contact?"}
133
+ description="This places real phone calls to people on the list and reserves credits for each attempt."
134
+ confirmLabel="Resume and dial"
135
+ recipients={count}
136
+ confirming={resume.loading}
137
+ onConfirm={() => void resume.mutate(undefined)}
138
+ onCancel={() => setAsking(null)}
139
+ />
140
+ <ConfirmSpendDialog
141
+ open={asking === "stop"}
142
+ tier="destructive"
143
+ title="Stop this session?"
144
+ description="Every live call is hung up and the session is completed. It cannot be resumed."
145
+ confirmLabel="Stop session"
146
+ affected={calls.filter(isLiveCall).length}
147
+ confirming={stop.loading}
148
+ onConfirm={() => void stop.mutate(undefined)}
149
+ onCancel={() => setAsking(null)}
150
+ />
151
+ </div>
152
+ );
153
+ }
@@ -18,8 +18,12 @@ import { EmptyState } from "@/g8/components/empty-state";
18
18
  import { g8Post } from "@/g8/lib/g8-client";
19
19
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
20
  import { contactName, isLiveCall, normalizeStatus, type LiveCall } from "../lib/dialer-types";
21
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
22
+ import { RecordLink } from "@/g8/components/screen/record-link";
21
23
 
22
24
  export interface LiveCallsTableProps {
25
+ /** URL param the host screen reads for this record type. With it the name is a real link (Cmd-click opens it in a new tab); without it a plain button. */
26
+ linkParam?: string;
23
27
  sessionId: string;
24
28
  calls: LiveCall[];
25
29
  /** Called after a control succeeded; refresh the session here. */
@@ -29,7 +33,7 @@ export interface LiveCallsTableProps {
29
33
  className?: string;
30
34
  }
31
35
 
32
- export function LiveCallsTable({ sessionId, calls, onChanged, onSelect, className }: LiveCallsTableProps) {
36
+ export function LiveCallsTable({ linkParam, sessionId, calls, onChanged, onSelect, className }: LiveCallsTableProps) {
33
37
  const [voicemailFor, setVoicemailFor] = useState<LiveCall | null>(null);
34
38
  // Per-row busy state: only the row being acted on is disabled, not every call in the table.
35
39
  const [busy, setBusy] = useState<ReadonlySet<string>>(new Set());
@@ -71,8 +75,8 @@ export function LiveCallsTable({ sessionId, calls, onChanged, onSelect, classNam
71
75
  const live = isLiveCall(call);
72
76
  const rowBusy = busy.has(call.call_id);
73
77
  return (
74
- <TableRow key={call.call_id} className={onSelect ? "cursor-pointer" : undefined} onClick={() => onSelect?.(call)}>
75
- <TableCell className="font-medium">{contactName(call)}</TableCell>
78
+ <TableRow key={call.call_id} className={onSelect ? "cursor-pointer" : undefined} {...(onSelect ? rowActivationProps(() => onSelect(call)) : {})}>
79
+ <TableCell className="font-medium">{onSelect ? <RecordLink param={linkParam} id={call.call_id} onOpen={() => onSelect(call)}>{contactName(call)}</RecordLink> : contactName(call)}</TableCell>
76
80
  <TableCell>{call.to_phone}</TableCell>
77
81
  <TableCell className="capitalize">{call.is_voicemail ? "voicemail" : normalizeStatus(call.status)}{call.disposition ? ` · ${call.disposition}` : ""}</TableCell>
78
82
  <TableCell className="space-x-1 text-right" onClick={(e) => e.stopPropagation()}>
@@ -16,6 +16,7 @@ import { g8Post } from "@/g8/lib/g8-client";
16
16
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
17
17
  import { useDialerNumbers } from "../hooks/use-dialer-numbers";
18
18
  import { dataOf, type DialerMode } from "../lib/dialer-types";
19
+ import { useBrand } from "@/g8/hooks/use-brand";
19
20
 
20
21
  export interface SessionStartFormProps {
21
22
  /** The rep these calls are attributed to. An organisation API key has no user of its own, so you name one. */
@@ -30,6 +31,7 @@ export interface SessionStartFormProps {
30
31
  }
31
32
 
32
33
  export function SessionStartForm({ userId, userEmail, listId: initialListId = "", listTitle, onCreated, className }: SessionStartFormProps) {
34
+ const brand = useBrand();
33
35
  const { numbers, loading, error, refetch } = useDialerNumbers();
34
36
  const [listId, setListId] = useState(initialListId);
35
37
  const [typedUserId, setTypedUserId] = useState("");
@@ -55,7 +57,7 @@ export function SessionStartForm({ userId, userEmail, listId: initialListId = ""
55
57
  user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
56
58
  });
57
59
  const sessionId = dataOf<{ session_id?: string }>(env).session_id;
58
- if (!sessionId) throw new Error("graph8 did not return a session id");
60
+ if (!sessionId) throw new Error(`${brand.linkLabel} did not return a session id`);
59
61
  return { sessionId, mode, parallelCount: count, fromPhone: chosenPhone };
60
62
  },
61
63
  { onSuccess: onCreated },
@@ -72,7 +74,7 @@ export function SessionStartForm({ userId, userEmail, listId: initialListId = ""
72
74
  {userId ? null : (
73
75
  <div className="space-y-1">
74
76
  <Label htmlFor="ds-user">Rep user id</Label>
75
- <Input id="ds-user" value={typedUserId} onChange={(e) => setTypedUserId(e.target.value)} placeholder="graph8 user id" />
77
+ <Input id="ds-user" value={typedUserId} onChange={(e) => setTypedUserId(e.target.value)} placeholder={`${brand.linkLabel} user id`} />
76
78
  </div>
77
79
  )}
78
80
  <div className="space-y-1">
@@ -22,6 +22,7 @@ import { g8Get } from "@/g8/lib/g8-client";
22
22
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
23
  import { dataOf } from "../lib/dialer-types";
24
24
  import type { SoftphoneTransport } from "../lib/softphone-transport";
25
+ import { useBrand } from "@/g8/hooks/use-brand";
25
26
 
26
27
  export interface SoftphoneProps {
27
28
  /** The rep whose identity the credential is minted for. */
@@ -38,6 +39,7 @@ export interface SoftphoneProps {
38
39
  export type SoftphoneState = "off" | "connecting" | "ready" | "in-call" | "error";
39
40
 
40
41
  export function Softphone({ userId, transport, conferenceName, joinMuted, onStateChange, className }: SoftphoneProps) {
42
+ const brand = useBrand();
41
43
  const [state, setState] = useState<SoftphoneState>("off");
42
44
  const [muted, setMuted] = useState(false);
43
45
  const [expiring, setExpiring] = useState(false);
@@ -61,7 +63,7 @@ export function Softphone({ userId, transport, conferenceName, joinMuted, onStat
61
63
  const mint = useG8Mutation(async () => {
62
64
  const env = await g8Get("/dialer/sessions/token", { user_id: userId });
63
65
  const { token, identity } = dataOf<{ token?: string; identity?: string }>(env);
64
- if (!token) throw new Error("graph8 did not return a softphone credential");
66
+ if (!token) throw new Error(`${brand.linkLabel} did not return a softphone credential`);
65
67
  return { token, identity };
66
68
  });
67
69
 
@@ -5,14 +5,26 @@ contacts of a list in one job, and manage the provider pipelines behind it. Buil
5
5
 
6
6
  Install: `npx @graph8/sdk add enrichment` (installs `foundation`, `crm` and `lists` first).
7
7
 
8
+ ## Screens
9
+
10
+ `EnrichmentScreen` is the finished enrichment page; mount it with no props. Tabs (`?tab=`): **Enrich a list** (pick a list,
11
+ tick contacts, run a bulk enrichment with preflight, confirmed price, progress and cancel; open a contact to look it up
12
+ or verify its email), **Look up** (one person or company, and email verification), **Pipelines** (a list's saved
13
+ provider waterfalls: create, delete) and **Providers** (graph8-funded vs your own key, credits per action; keys are
14
+ added in graph8 settings, so it links out). Every paid step goes through the confirmation dialog.
15
+ Also a **Jobs** tab (`?job=`, `EnrichmentJobDetail`: live progress, counts, credits, provider errors, cancel; jobs started this session are listed, any job id can be opened) and a pipeline detail (`?pipeline=`: steps, credential status, missing keys). `?view=full` opens any detail full-page.
16
+ Slots: `toolbarActions`, `renderDetailActions(contact)`, `extraDetailTabs`.
17
+
18
+ Prerequisites: `crm` and `lists` installed, proxy allowlist `enrichment`, `lists`, `contacts`, `companies`; provider keys and credits as below.
19
+
8
20
  ## What you get
9
21
 
10
22
  | Component | Use it for | Props |
11
23
  |---|---|---|
12
24
  | `EnrichButton` | Look up one person/company; optionally apply the found fields to the record | `kind`, `lookup`, `record`, `onResult`, `onApplied` |
13
- | `EmailVerifyBadge` | Status chip (Unverified / Valid / Risky / Invalid) with a Verify action | `email`, `initialStatus`, `onVerified`, `readOnly` |
25
+ | `EmailVerifyBadge` | Status chip (Unverified / Valid / Risky / Invalid / Couldn't verify) read from the verifier's own result codes: catch-all (`ok_for_all`) is Risky, a hard failure (`email_disabled`, `invalid_mx`, `dead_server`, `error`, ...) is Invalid and offers no second paid Verify, `unknown` (what the endpoint answers when the verifier itself fails, uncharged) is "Couldn't verify" with Try again. The verdict is shown, not saved to the contact (`POST /enrichment/verify-email` takes only `email`) | `email`, `initialStatus`, `onVerified`, `readOnly` |
14
26
  | `BulkEnrichPanel` | Fill work email / phones for many contacts of a list: preflight, estimate, job, progress | `listId`, `contactIds`, `defaultProviders`, `onDone` |
15
- | `WaterfallConfig` | Saved pipelines of a list and a form to create one | `listId`, `onChanged` |
27
+ | `WaterfallConfig` | Saved pipelines of a list and a form to create one. Creating is idempotent per field and list: if the list already has a pipeline for the field, the API returns it (`created: false`, its own providers) and the form says "Already exists, reused" | `listId`, `onChanged` |
16
28
 
17
29
  `BulkEnrichPanel` with no props is a complete page (choose a list, tick contacts, enrich), so it can be mounted as a
18
30
  route as is; give it `listId` and `contactIds` to embed it. For that reason this block depends on `lists`.