@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,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`.
@@ -7,7 +7,8 @@
7
7
  "single person/company lookup that can apply found fields to the record",
8
8
  "email verification chip with a one-click verify",
9
9
  "bulk enrichment with a free preflight, credit estimate confirmation, polled progress and cancel",
10
- "waterfall pipeline manager with provider order and credential readiness"
10
+ "waterfall pipeline manager with provider order and credential readiness",
11
+ "finished EnrichmentScreen page"
11
12
  ],
12
13
  "dependsOn": [
13
14
  "lists"
@@ -18,6 +19,8 @@
18
19
  "create_list_lists_post",
19
20
  "delete_contact_contacts__contact_id__delete",
20
21
  "delete_waterfall_config_enrichment_waterfall_configs__column_id__delete",
22
+ "get_company_companies__company_id__get",
23
+ "get_contact_contacts__contact_id__get",
21
24
  "get_enrichment_job_enrichment_jobs__job_id__get",
22
25
  "list_contact_fields_fields_get",
23
26
  "list_contacts_contacts_get",
@@ -41,9 +44,12 @@
41
44
  "lookup_person_enrichment_lookup_person_post",
42
45
  "lookup_company_enrichment_lookup_company_post",
43
46
  "update_contact_contacts__contact_id__patch",
44
- "update_company_companies__company_id__patch"
47
+ "update_company_companies__company_id__patch",
48
+ "get_contact_contacts__contact_id__get",
49
+ "get_company_companies__company_id__get"
45
50
  ],
46
51
  "tiers": [
52
+ "read",
47
53
  "write",
48
54
  "billable"
49
55
  ]
@@ -51,7 +57,7 @@
51
57
  {
52
58
  "name": "EmailVerifyBadge",
53
59
  "file": "components/email-verify-badge.tsx",
54
- "purpose": "Status chip for an email (Unverified, Valid, Risky, Invalid) with a confirmed 1-credit Verify action; never verifies a locked email.",
60
+ "purpose": "Status chip for an email (Unverified, Valid, Risky, Invalid, Couldn't verify) from the verifier's own result codes, with a confirmed 1-credit Verify action; never verifies a locked email.",
55
61
  "operations": [
56
62
  "verify_email_enrichment_verify_email_post"
57
63
  ],
@@ -97,6 +103,26 @@
97
103
  "billable",
98
104
  "destructive"
99
105
  ]
106
+ },
107
+ {
108
+ "name": "EnrichmentScreen",
109
+ "file": "components/enrichment-screen.tsx",
110
+ "purpose": "Finished enrichment page: enrich a list, one-off lookups and email verification, pipelines and the provider catalog.",
111
+ "operations": [],
112
+ "tiers": []
113
+ },
114
+ {
115
+ "name": "EnrichmentJobDetail",
116
+ "file": "components/enrichment-job-detail.tsx",
117
+ "purpose": "One bulk enrichment job: live progress, counts, credits, warnings, provider errors and cancel.",
118
+ "operations": [
119
+ "cancel_enrichment_job_enrichment_jobs__job_id__cancel_post",
120
+ "get_enrichment_job_enrichment_jobs__job_id__get"
121
+ ],
122
+ "tiers": [
123
+ "read",
124
+ "write"
125
+ ]
100
126
  }
101
127
  ],
102
128
  "hooks": [
@@ -129,7 +155,7 @@
129
155
  {
130
156
  "file": "recipes/waterfall-pipeline.ts",
131
157
  "title": "Create a waterfall pipeline for a list and see which providers are ready",
132
- "when": "Setting up \"try provider A, then B\" enrichment for a column once, then reusing it. Check the provider catalog first: graph8-funded providers spend credits when the pipeline runs, providers on your own key do not, and a provider without a key is skipped. Creating is idempotent by (list_id, name)."
158
+ "when": "Setting up \"try provider A, then B\" enrichment for a column once, then reusing it. Check the provider catalog first: graph8-funded providers spend credits when the pipeline runs, providers on your own key do not, and a provider without a key is skipped. Creating with `field` is idempotent per field and list: if the list already has a pipeline for that field it is returned with `created: false` and keeps its own providers, whatever name or providers you send (delete it first to change them). Without `field`, a pipeline is reused by (list_id, name)."
133
159
  }
134
160
  ],
135
161
  "prerequisites": [
@@ -159,6 +185,7 @@
159
185
  }
160
186
  ],
161
187
  "scopes": [
188
+ "companies:read",
162
189
  "companies:write",
163
190
  "contacts:delete",
164
191
  "contacts:read",
@@ -199,6 +226,7 @@
199
226
  }
200
227
  ],
201
228
  "peerDependencies": {
229
+ "lucide-react": ">=0.400.0",
202
230
  "react": ">=18"
203
231
  }
204
232
  }
@@ -31,9 +31,11 @@ import { useJobPoll } from "@/g8/hooks/use-job-poll";
31
31
  import { g8Get, g8Post } from "@/g8/lib/g8-client";
32
32
  import { ContactTable } from "@/g8/crm/components/contact-table";
33
33
  import { ListPicker } from "@/g8/lists/components/list-picker";
34
+ import { plural } from "@/g8/lib/plural";
34
35
  import { useProviders } from "../hooks/use-providers";
35
36
  import { CREDITS_PER_RECORD_ESTIMATE, TARGET_FIELDS, estimateCredits, isJobTerminal } from "../lib/enrichment-types";
36
37
  import type { EnrichJob, Preflight, TargetField } from "../lib/enrichment-types";
38
+ import { useBrand } from "@/g8/hooks/use-brand";
37
39
 
38
40
  export interface BulkEnrichPanelProps {
39
41
  /** With `listId` and `contactIds` it is just the enrichment panel. Omit them and it is a whole page: pick a list, tick contacts, enrich. */
@@ -44,12 +46,15 @@ export interface BulkEnrichPanelProps {
44
46
  defaultField?: TargetField;
45
47
  /** Called when the job reached a final state. */
46
48
  onDone?: (job: EnrichJob) => void;
49
+ /** Called with the job id as soon as a run starts (to list it in a job history). */
50
+ onStarted?: (jobId: string) => void;
47
51
  className?: string;
48
52
  }
49
53
 
50
54
  const DEFAULT_PROVIDERS = ["prospeo", "dropcontact"];
51
55
 
52
- function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDERS, defaultField = "work_email", onDone, className }: BulkEnrichPanelProps & { listId: number; contactIds: number[] }) {
56
+ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDERS, defaultField = "work_email", onDone, onStarted, className }: BulkEnrichPanelProps & { listId: number; contactIds: number[] }) {
57
+ const brand = useBrand();
53
58
  const [field, setField] = useState<TargetField>(defaultField);
54
59
  const [chosen, setChosen] = useState<string[] | null>(null);
55
60
  const [confirmOpen, setConfirmOpen] = useState(false);
@@ -71,7 +76,7 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
71
76
  const runnable = quote ? quote.contact_count - quote.not_in_list.length : ids.length;
72
77
 
73
78
  const start = useG8Mutation(() => g8Post<{ job_id: string }>("/enrichment/enrich", { contact_ids: ids, list_id: listId, fields_config: { [column.column]: providers } }), {
74
- onSuccess: (res) => { setConfirmOpen(false); setJobId(res.data.job_id); },
79
+ onSuccess: (res) => { setConfirmOpen(false); setJobId(res.data.job_id); onStarted?.(res.data.job_id); },
75
80
  });
76
81
  const job = useJobPoll<EnrichJob>(
77
82
  async (signal) => (await g8Get<EnrichJob>(`/enrichment/jobs/${jobId}`, undefined, { signal })).data,
@@ -128,7 +133,7 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
128
133
  {TARGET_FIELDS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
129
134
  </select>
130
135
  </label>
131
- <span className="text-sm text-muted-foreground">for {ids.length.toLocaleString()} contact{ids.length === 1 ? "" : "s"}</span>
136
+ <span className="text-sm text-muted-foreground">for {plural(ids.length, "contact")}</span>
132
137
  </div>
133
138
 
134
139
  <fieldset className="flex flex-col gap-1">
@@ -154,7 +159,7 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
154
159
  <div className="rounded-md border p-3 text-sm" aria-label="Preflight">
155
160
  <p><strong>{runnable.toLocaleString()}</strong> of {quote.contact_count.toLocaleString()} contacts will be enriched{quote.not_in_list.length ? `; ${quote.not_in_list.length} are not on this list and are skipped (not charged)` : ""}{quote.duplicates_removed ? `; ${quote.duplicates_removed} duplicates removed` : ""}.</p>
156
161
  <p className="mt-1">{quote.max_total_credits != null ? `At most ${quote.max_total_credits.toLocaleString()} credits (${quote.credits_per_record} per record).` : `About ${credits.toLocaleString()} credits (${CREDITS_PER_RECORD_ESTIMATE} per record, the API's flat estimate).`} Records that already have a value or find nothing cost less.</p>
157
- {quote.missing_credentials.length ? <p role="alert" className="mt-1 text-amber-700">No API key for {quote.missing_credentials.join(", ")}: those steps are skipped. Add the keys in graph8 settings.</p> : null}
162
+ {quote.missing_credentials.length ? <p role="alert" className="mt-1 text-amber-700">No API key for {quote.missing_credentials.join(", ")}: those steps are skipped. Add the keys in {brand.linkLabel} settings.</p> : null}
158
163
  {quote.warnings.map((w) => <p key={w} className="mt-1 text-xs text-muted-foreground">{w}</p>)}
159
164
  </div>
160
165
  ) : null}
@@ -162,7 +167,7 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
162
167
 
163
168
  <div className="flex justify-end">
164
169
  <Button disabled={ids.length === 0 || providers.length === 0 || preflight.loading || runnable === 0 || Boolean(preflight.error)} onClick={() => setConfirmOpen(true)}>
165
- Enrich {runnable.toLocaleString()} contact{runnable === 1 ? "" : "s"}
170
+ Enrich {plural(runnable, "contact")}
166
171
  </Button>
167
172
  </div>
168
173
 
@@ -4,6 +4,12 @@
4
4
  * EmailVerifyBadge: a small status chip next to an email address (Unverified, Valid, Risky, Invalid) with
5
5
  * a "Verify" action.
6
6
  *
7
+ * The verdict comes from the verifier's own result code (EmailListVerify and friends; the backend's vocabulary is
8
+ * shared/services/email_verification_classifier.py): `ok`/`valid` is Valid; catch-all codes (`ok_for_all`,
9
+ * `accept_all`) are Risky; hard failures (`email_disabled`, `invalid_mx`, `dead_server`, `no_mx`, ...) are Invalid, so a
10
+ * known-bad address is never offered a second paid Verify; `unknown`/`error` (the verifier was inconclusive or the call
11
+ * failed) is "Couldn't verify", which can be retried.
12
+ *
7
13
  * SDK operations, in order:
8
14
  * 1. verifyEmail POST /enrichment/verify-email { email } (billable)
9
15
  * Goes through ConfirmSpendDialog first: 1 credit per verification.
@@ -20,7 +26,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
26
  import { g8Post } from "@/g8/lib/g8-client";
21
27
  import { CREDITS_PER_EMAIL_VERIFICATION, isMaskedValue } from "../lib/enrichment-types";
22
28
 
23
- export type VerifyState = "unverified" | "valid" | "risky" | "invalid";
29
+ export type VerifyState = "unverified" | "valid" | "risky" | "invalid" | "unverifiable";
24
30
 
25
31
  export interface EmailVerifyBadgeProps {
26
32
  email?: string | null;
@@ -32,13 +38,26 @@ export interface EmailVerifyBadgeProps {
32
38
  className?: string;
33
39
  }
34
40
 
35
- /** Collapse the verifier's vocabulary into the four states the chip shows. */
41
+ // The verifier's result codes, normalised to lowercase with "-" (shared/services/email_verification_classifier.py).
42
+ // `error` is EmailListVerify's "delivery failed" and the classifier counts it NOT_VALID, so it is Invalid here; a
43
+ // vendor failure reaches the client as `unknown` (the endpoint catches it and charges nothing). `role-based` and
44
+ // `timeout` are not in the backend vocabulary; they are kept as harmless aliases from other verifiers.
45
+ const VALID = new Set(["valid", "ok", "deliverable", "ultra-sure", "sure"]);
46
+ const RISKY = new Set(["catch-all", "accept-all", "ok-for-all", "role-based"]);
47
+ const INVALID = new Set([
48
+ "invalid", "fail", "undeliverable", "unlikely", "unsure", "email-disabled", "dead-server", "invalid-mx", "invalid-syntax",
49
+ "bad-mx", "no-mx", "abuse", "do-not-mail", "spamtrap", "disposable", "bounced", "error",
50
+ ]);
51
+ const UNVERIFIABLE = new Set(["unknown", "unverifiable", "smtp-protocol", "antispam-system", "timeout"]);
52
+
53
+ /** Collapse the verifier's vocabulary into the states the chip shows. */
36
54
  export function classifyVerification(status: string | null | undefined, isValid?: boolean): VerifyState {
37
- const s = String(status ?? "").toLowerCase().replace(/_/g, "-");
55
+ const s = String(status ?? "").trim().toLowerCase().replace(/[_\s]+/g, "-");
38
56
  if (!s) return "unverified";
39
- if (["catch-all", "unknown", "role-based", "accept-all"].includes(s)) return "risky";
40
- if (s === "valid") return "valid";
41
- if (["invalid", "do-not-mail", "spamtrap", "abuse", "disposable", "bounced"].includes(s)) return "invalid";
57
+ if (RISKY.has(s)) return "risky";
58
+ if (VALID.has(s)) return "valid";
59
+ if (INVALID.has(s)) return "invalid";
60
+ if (UNVERIFIABLE.has(s)) return "unverifiable";
42
61
  return isValid === true ? "valid" : "unverified"; // an unrecognised status is not proof of a bad address
43
62
  }
44
63
 
@@ -47,8 +66,9 @@ const STYLE: Record<VerifyState, string> = {
47
66
  valid: "bg-emerald-100 text-emerald-800",
48
67
  risky: "bg-amber-100 text-amber-800",
49
68
  invalid: "bg-red-100 text-red-800",
69
+ unverifiable: "bg-muted text-muted-foreground",
50
70
  };
51
- const LABEL: Record<VerifyState, string> = { unverified: "Unverified", valid: "Valid", risky: "Risky", invalid: "Invalid" };
71
+ const LABEL: Record<VerifyState, string> = { unverified: "Unverified", valid: "Valid", risky: "Risky", invalid: "Invalid", unverifiable: "Couldn't verify" };
52
72
 
53
73
  export function EmailVerifyBadge({ email, initialStatus, onVerified, readOnly, className }: EmailVerifyBadgeProps) {
54
74
  const [confirmOpen, setConfirmOpen] = useState(false);
@@ -72,7 +92,7 @@ export function EmailVerifyBadge({ email, initialStatus, onVerified, readOnly, c
72
92
  return (
73
93
  <span className={`inline-flex items-center gap-1 ${className ?? ""}`}>
74
94
  <span data-state={state} title={detail ?? undefined} className={`rounded-full px-2 py-0.5 text-xs font-medium ${STYLE[state]}`}>{LABEL[state]}</span>
75
- {!readOnly && state === "unverified" ? <Button variant="link" size="sm" className="h-auto p-0 text-xs" onClick={() => setConfirmOpen(true)}>Verify</Button> : null}
95
+ {!readOnly && (state === "unverified" || state === "unverifiable") ? <Button variant="link" size="sm" className="h-auto p-0 text-xs" onClick={() => setConfirmOpen(true)}>{state === "unverifiable" ? "Try again" : "Verify"}</Button> : null}
76
96
  {verify.error ? <ApiError error={verify.error} title="Could not verify" /> : null}
77
97
  <ConfirmSpendDialog open={confirmOpen} tier="billable" title={`Verify ${email}`} description="Checks whether the address can receive mail."
78
98
  estimatedCredits={CREDITS_PER_EMAIL_VERIFICATION} confirmLabel="Verify" confirming={verify.loading}
@@ -13,6 +13,11 @@
13
13
  *
14
14
  * Apply is opt-in per field: fields the record already has are shown unticked so a lookup never silently
15
15
  * overwrites good data. A locked ("***") value on the record counts as empty.
16
+ *
17
+ * "Already has" is judged on the FULL record. A list row can omit fields the record has, so unless the caller says
18
+ * `recordLoaded` (it passed the full record from GET /contacts/{id} or /companies/{id}), the button reads the full
19
+ * record itself right after the lookup (GET /contacts/{id} | /companies/{id}, read). If that read fails nothing is
20
+ * pre-ticked and the dialog says existing values were not checked. The record id is captured when the lookup runs.
16
21
  */
17
22
 
18
23
  import { useState } from "react";
@@ -20,7 +25,8 @@ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogH
20
25
  import { ApiError } from "@/g8/components/api-error";
21
26
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
22
27
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
- import { g8Patch, g8Post } from "@/g8/lib/g8-client";
28
+ import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
29
+ import { plural } from "@/g8/lib/plural";
24
30
  import { CREDITS_PER_FOUND_LOOKUP, isMaskedValue } from "../lib/enrichment-types";
25
31
 
26
32
  export interface PersonLookup { email?: string; linkedin_url?: string; first_name?: string; last_name?: string; company_domain?: string }
@@ -32,6 +38,8 @@ export interface EnrichButtonProps {
32
38
  lookup: PersonLookup | CompanyLookup;
33
39
  /** The CRM record the result can be applied to (its current values decide what is pre-ticked). */
34
40
  record?: { id: number } & Record<string, unknown>;
41
+ /** True when `record` is the full record (not a list row). Default false: the button then reads the full record itself before pre-ticking. */
42
+ recordLoaded?: boolean;
35
43
  label?: string;
36
44
  variant?: "default" | "outline" | "secondary" | "ghost";
37
45
  size?: "default" | "sm";
@@ -60,25 +68,39 @@ function hasIdentity(kind: EnrichButtonProps["kind"], l: PersonLookup & CompanyL
60
68
  return kind === "person" ? Boolean(l.email || l.linkedin_url || (l.first_name && l.last_name && l.company_domain)) : Boolean(l.domain || l.name);
61
69
  }
62
70
 
63
- export function EnrichButton({ kind, lookup, record, label, variant = "outline", size = "sm", onResult, onApplied, className }: EnrichButtonProps) {
71
+ export function EnrichButton({ kind, lookup, record, recordLoaded = false, label, variant = "outline", size = "sm", onResult, onApplied, className }: EnrichButtonProps) {
64
72
  const [confirmOpen, setConfirmOpen] = useState(false);
65
73
  const [result, setResult] = useState<LookupResult | null>(null);
66
74
  const [ticked, setTicked] = useState<Set<string>>(new Set());
67
75
  const [applied, setApplied] = useState(false);
76
+ /** What the record held when the lookup ran: its id, its full values, and whether those values could be read. */
77
+ const [target, setTarget] = useState<{ id: number; existing: Record<string, unknown>; checked: boolean } | null>(null);
68
78
  const clean = Object.fromEntries(Object.entries(lookup).filter(([, v]) => typeof v === "string" && v.trim() && !isMaskedValue(v)));
69
79
  const ready = hasIdentity(kind, clean);
80
+ const path = (id: number): string => `/${kind === "person" ? "contacts" : "companies"}/${id}`;
70
81
 
71
- const run = useG8Mutation(() => g8Post<LookupResult>(`/enrichment/lookup/${kind}`, clean), {
72
- onSuccess: (res) => {
82
+ const run = useG8Mutation(async () => {
83
+ const res = await g8Post<LookupResult>(`/enrichment/lookup/${kind}`, clean);
84
+ if (!record) return { res, target: null };
85
+ if (recordLoaded) return { res, target: { id: record.id, existing: record, checked: true } };
86
+ try {
87
+ const full = await g8Get<Record<string, unknown>>(path(record.id));
88
+ return { res, target: { id: record.id, existing: full.data ?? {}, checked: true } };
89
+ } catch {
90
+ return { res, target: { id: record.id, existing: {}, checked: false } };
91
+ }
92
+ }, {
93
+ onSuccess: ({ res, target: t }) => {
73
94
  setConfirmOpen(false);
74
95
  setResult(res.data);
75
96
  setApplied(false);
97
+ setTarget(t);
76
98
  const fields = res.data.data ? normalise(kind, res.data.data) : {};
77
- setTicked(new Set(Object.keys(fields).filter((k) => !record || !record[k] || isMaskedValue(record[k]))));
99
+ setTicked(new Set(t?.checked ? Object.keys(fields).filter((k) => !t.existing[k] || isMaskedValue(t.existing[k])) : []));
78
100
  onResult?.(res.data);
79
101
  },
80
102
  });
81
- const apply = useG8Mutation((values: Record<string, string>) => g8Patch(`/${kind === "person" ? "contacts" : "companies"}/${record!.id}`, values), {
103
+ const apply = useG8Mutation((values: Record<string, string>) => g8Patch(path(target!.id), values), {
82
104
  onSuccess: (_r, values) => { setApplied(true); onApplied?.(values); },
83
105
  });
84
106
 
@@ -114,19 +136,20 @@ export function EnrichButton({ kind, lookup, record, label, variant = "outline",
114
136
  <ul className="flex flex-col gap-1 text-sm" aria-label="Found fields">
115
137
  {Object.entries(fields).map(([k, v]) => (
116
138
  <li key={k} className="flex items-center gap-2">
117
- {record ? <input type="checkbox" aria-label={`Apply ${k}`} checked={ticked.has(k)} onChange={() => setTicked((t) => { const n = new Set(t); if (!n.delete(k)) n.add(k); return n; })} /> : null}
139
+ {target ? <input type="checkbox" aria-label={`Apply ${k}`} checked={ticked.has(k)} onChange={() => setTicked((t) => { const n = new Set(t); if (!n.delete(k)) n.add(k); return n; })} /> : null}
118
140
  <span className="w-36 text-muted-foreground">{k.replace(/_/g, " ")}</span><span className="truncate">{v}</span>
119
- {record && record[k] && !isMaskedValue(record[k]) && record[k] !== v ? <span className="text-xs text-amber-600">replaces {String(record[k])}</span> : null}
141
+ {target?.checked && target.existing[k] && !isMaskedValue(target.existing[k]) && target.existing[k] !== v ? <span className="text-xs text-amber-600">replaces {String(target.existing[k])}</span> : null}
120
142
  </li>
121
143
  ))}
122
144
  {Object.keys(fields).length === 0 ? <li className="text-muted-foreground">The match had no fields to show.</li> : null}
123
145
  </ul>
124
146
  ) : null}
147
+ {target && !target.checked && result?.found ? <p role="status" className="text-sm text-muted-foreground">Existing values were not checked, so nothing is ticked. Tick only what you want to write.</p> : null}
125
148
  {apply.error ? <ApiError error={apply.error} title="Could not update the record" /> : null}
126
149
  {applied ? <p role="status" className="text-sm">Updated the record.</p> : null}
127
150
  <DialogFooter>
128
151
  <Button variant="ghost" onClick={() => setResult(null)}>Close</Button>
129
- {result?.found && record && !applied ? <Button loading={apply.loading} disabled={Object.keys(chosen).length === 0} onClick={() => void apply.mutate(chosen)}>Apply {Object.keys(chosen).length} field{Object.keys(chosen).length === 1 ? "" : "s"}</Button> : null}
152
+ {result?.found && target && !applied ? <Button loading={apply.loading} disabled={Object.keys(chosen).length === 0} onClick={() => void apply.mutate(chosen)}>Apply {plural(Object.keys(chosen).length, "field")}</Button> : null}
130
153
  </DialogFooter>
131
154
  </DialogContent>
132
155
  </Dialog>