@graph8/sdk 0.362.0 → 0.373.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 (615) hide show
  1. package/README.md +33 -10
  2. package/blocks/_foundation/README.md +78 -1
  3. package/blocks/_foundation/block.json +123 -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/confirm-spend-dialog.tsx +15 -8
  7. package/blocks/_foundation/components/confirmed-action.tsx +90 -0
  8. package/blocks/_foundation/components/connect-graph8.tsx +280 -0
  9. package/blocks/_foundation/components/empty-state.tsx +5 -2
  10. package/blocks/_foundation/components/list-select.tsx +112 -0
  11. package/blocks/_foundation/components/paging-notices.tsx +55 -0
  12. package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
  13. package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
  14. package/blocks/_foundation/components/screen/index.ts +2 -0
  15. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  16. package/blocks/_foundation/components/screen/split-view.tsx +1 -1
  17. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  18. package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
  19. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  20. package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
  21. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  22. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  23. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  24. package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
  25. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  26. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  27. package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
  28. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  29. package/blocks/_foundation/lib/g8-client.ts +1 -1
  30. package/blocks/_foundation/lib/paging.ts +57 -0
  31. package/blocks/_foundation/lib/plural.ts +23 -0
  32. package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
  33. package/blocks/_foundation/server/connect-route.ts +15 -0
  34. package/blocks/_foundation/server/connect.ts +127 -0
  35. package/blocks/_foundation/server/local-key.ts +70 -0
  36. package/blocks/_foundation/server/proxy.ts +103 -3
  37. package/blocks/_foundation/server/route.ts +16 -4
  38. package/blocks/agents/README.md +93 -2
  39. package/blocks/agents/block.json +343 -2
  40. package/blocks/agents/components/agent-activity.tsx +112 -0
  41. package/blocks/agents/components/agent-approvals.tsx +135 -0
  42. package/blocks/agents/components/agent-builder.tsx +29 -10
  43. package/blocks/agents/components/agent-conversations.tsx +143 -0
  44. package/blocks/agents/components/agent-knowledge-panel.tsx +4 -3
  45. package/blocks/agents/components/agent-operators.tsx +164 -0
  46. package/blocks/agents/components/agent-run-detail.tsx +119 -0
  47. package/blocks/agents/components/agent-runs.tsx +19 -10
  48. package/blocks/agents/components/agent-skills-panel.tsx +7 -7
  49. package/blocks/agents/components/agent-test-history.tsx +121 -0
  50. package/blocks/agents/components/agents-screen.tsx +226 -0
  51. package/blocks/agents/components/assistant-sessions.tsx +150 -0
  52. package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
  53. package/blocks/agents/components/from-template-button.tsx +65 -0
  54. package/blocks/agents/components/knowledge-browser.tsx +107 -0
  55. package/blocks/agents/components/knowledge-detail.tsx +156 -0
  56. package/blocks/agents/components/knowledge-form.tsx +69 -0
  57. package/blocks/agents/components/name-dialog.tsx +32 -0
  58. package/blocks/agents/components/receptionist-button.tsx +46 -0
  59. package/blocks/agents/components/save-as-template-button.tsx +32 -0
  60. package/blocks/agents/components/skill-editor.tsx +147 -0
  61. package/blocks/agents/components/skill-library.tsx +111 -0
  62. package/blocks/agents/lib/agent-types.ts +7 -1
  63. package/blocks/agents/lib/facts.tsx +13 -0
  64. package/blocks/agents/lib/knowledge-utils.ts +49 -0
  65. package/blocks/agents/lib/load-more.tsx +141 -0
  66. package/blocks/agents/lib/skill-utils.ts +91 -0
  67. package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
  68. package/blocks/analytics/README.md +13 -0
  69. package/blocks/analytics/block.json +45 -1
  70. package/blocks/analytics/components/analytics-screen.tsx +222 -0
  71. package/blocks/analytics/components/kpi-tiles.tsx +1 -1
  72. package/blocks/analytics/components/leaderboard.tsx +14 -7
  73. package/blocks/analytics/components/report-builder.tsx +38 -11
  74. package/blocks/analytics/components/report-detail.tsx +84 -0
  75. package/blocks/analytics/components/trend-chart.tsx +62 -0
  76. package/blocks/appointments/README.md +6 -0
  77. package/blocks/appointments/block.json +33 -2
  78. package/blocks/appointments/components/availability-editor.tsx +17 -5
  79. package/blocks/appointments/components/booking-page.tsx +2 -2
  80. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  81. package/blocks/appointments/components/event-type-related.tsx +4 -2
  82. package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
  83. package/blocks/appointments/components/meeting-detail.tsx +2 -2
  84. package/blocks/appointments/components/meeting-list.tsx +38 -18
  85. package/blocks/appointments/components/meetings-screen.tsx +7 -4
  86. package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
  87. package/blocks/appointments/components/scheduling-screen.tsx +2 -1
  88. package/blocks/block.schema.json +18 -0
  89. package/blocks/block.types.ts +17 -0
  90. package/blocks/campaigns/README.md +41 -11
  91. package/blocks/campaigns/block.json +105 -42
  92. package/blocks/campaigns/components/campaign-assets.tsx +17 -6
  93. package/blocks/campaigns/components/campaign-launch-step.tsx +114 -26
  94. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  95. package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
  96. package/blocks/campaigns/components/campaign-wizard.tsx +52 -14
  97. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  98. package/blocks/campaigns/components/icp-detail.tsx +163 -0
  99. package/blocks/campaigns/components/icp-editor.tsx +22 -17
  100. package/blocks/campaigns/components/persona-detail.tsx +181 -0
  101. package/blocks/campaigns/components/persona-editor.tsx +19 -11
  102. package/blocks/campaigns/lib/campaigns-utils.ts +138 -5
  103. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  104. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  105. package/blocks/coach/README.md +10 -0
  106. package/blocks/coach/block.json +16 -1
  107. package/blocks/coach/components/call-scorecard.tsx +7 -7
  108. package/blocks/coach/components/coach-screen.tsx +222 -0
  109. package/blocks/company-intelligence/README.md +132 -0
  110. package/blocks/company-intelligence/block.json +318 -0
  111. package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
  112. package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
  113. package/blocks/company-intelligence/components/company-profile.tsx +68 -0
  114. package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
  115. package/blocks/company-intelligence/components/report-detail.tsx +189 -0
  116. package/blocks/company-intelligence/components/research-reports.tsx +136 -0
  117. package/blocks/company-intelligence/components/research-run.tsx +161 -0
  118. package/blocks/company-intelligence/components/source-detail.tsx +117 -0
  119. package/blocks/company-intelligence/components/source-view.tsx +274 -0
  120. package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
  121. package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
  122. package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
  123. package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
  124. package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
  125. package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
  126. package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
  127. package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
  128. package/blocks/copilot/README.md +13 -0
  129. package/blocks/copilot/block.json +14 -1
  130. package/blocks/copilot/components/copilot-memory.tsx +6 -2
  131. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  132. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  133. package/blocks/copilot/components/copilot-team-chat.tsx +13 -6
  134. package/blocks/crm/README.md +34 -0
  135. package/blocks/crm/block.json +107 -2
  136. package/blocks/crm/components/companies-screen.tsx +359 -0
  137. package/blocks/crm/components/company-detail.tsx +10 -8
  138. package/blocks/crm/components/company-table.tsx +11 -4
  139. package/blocks/crm/components/contact-detail.tsx +10 -7
  140. package/blocks/crm/components/contact-lookup-link.tsx +44 -0
  141. package/blocks/crm/components/contact-table.tsx +10 -3
  142. package/blocks/crm/components/contacts-screen.tsx +377 -0
  143. package/blocks/crm/components/dedupe-review.tsx +13 -6
  144. package/blocks/crm/components/import-wizard.tsx +7 -9
  145. package/blocks/crm/components/list-select.tsx +20 -0
  146. package/blocks/crm/components/notes-panel.tsx +4 -4
  147. package/blocks/crm/components/record-engagement-tab.tsx +103 -0
  148. package/blocks/crm/components/record-form.tsx +6 -4
  149. package/blocks/crm/components/task-list.tsx +4 -4
  150. package/blocks/crm/components/tasks-screen.tsx +399 -0
  151. package/blocks/crm/hooks/use-all-lists.ts +18 -0
  152. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  153. package/blocks/crm/hooks/use-page-query.ts +9 -1
  154. package/blocks/crm/lib/crm-types.ts +26 -2
  155. package/blocks/crm/lib/read-lists.ts +16 -0
  156. package/blocks/crm/lib/screen-parts.tsx +105 -0
  157. package/blocks/deals/README.md +24 -6
  158. package/blocks/deals/block.json +160 -5
  159. package/blocks/deals/components/close-deal-dialog.tsx +169 -0
  160. package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
  161. package/blocks/deals/components/deal-detail.tsx +25 -13
  162. package/blocks/deals/components/deal-filters.tsx +65 -0
  163. package/blocks/deals/components/deal-line-items.tsx +120 -0
  164. package/blocks/deals/components/deal-notes-tab.tsx +97 -0
  165. package/blocks/deals/components/deal-related.tsx +31 -10
  166. package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
  167. package/blocks/deals/components/deals-screen.tsx +66 -26
  168. package/blocks/deals/components/forecast-view.tsx +4 -4
  169. package/blocks/deals/components/pipeline-board.tsx +8 -6
  170. package/blocks/deals/components/pipeline-editor.tsx +132 -0
  171. package/blocks/deals/components/record-deals-tab.tsx +62 -0
  172. package/blocks/deals/components/stage-editor.tsx +5 -5
  173. package/blocks/deals/hooks/use-pipeline-deals.ts +21 -4
  174. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  175. package/blocks/deals/lib/deal-extras.ts +156 -0
  176. package/blocks/dialer/README.md +45 -4
  177. package/blocks/dialer/block.json +84 -3
  178. package/blocks/dialer/components/call-contact-button.tsx +269 -0
  179. package/blocks/dialer/components/call-log-screen.tsx +208 -0
  180. package/blocks/dialer/components/call-log.tsx +23 -7
  181. package/blocks/dialer/components/call-queue.tsx +4 -4
  182. package/blocks/dialer/components/dialer-screen.tsx +361 -0
  183. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  184. package/blocks/dialer/components/disposition-panel.tsx +1 -1
  185. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  186. package/blocks/dialer/components/record-calls-tab.tsx +101 -0
  187. package/blocks/dialer/components/session-start-form.tsx +61 -12
  188. package/blocks/dialer/components/softphone.tsx +4 -2
  189. package/blocks/enrichment/README.md +29 -7
  190. package/blocks/enrichment/block.json +49 -9
  191. package/blocks/enrichment/components/bulk-enrich-panel.tsx +83 -29
  192. package/blocks/enrichment/components/email-verify-badge.tsx +31 -11
  193. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  194. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  195. package/blocks/enrichment/components/enrichment-screen.tsx +341 -0
  196. package/blocks/enrichment/components/waterfall-config.tsx +66 -17
  197. package/blocks/enrichment/lib/enrichment-types.ts +39 -0
  198. package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
  199. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  200. package/blocks/forms/README.md +8 -0
  201. package/blocks/forms/block.json +139 -18
  202. package/blocks/forms/components/form-activity.tsx +71 -0
  203. package/blocks/forms/components/form-backfill.tsx +108 -0
  204. package/blocks/forms/components/form-builder.tsx +4 -2
  205. package/blocks/forms/components/form-embed.tsx +4 -2
  206. package/blocks/forms/components/form-groups.tsx +151 -0
  207. package/blocks/forms/components/form-mapping-editor.tsx +6 -9
  208. package/blocks/forms/components/form-route-history.tsx +72 -0
  209. package/blocks/forms/components/form-saved-views.tsx +81 -0
  210. package/blocks/forms/components/forms-screen.tsx +56 -19
  211. package/blocks/forms/components/more-rows.tsx +30 -0
  212. package/blocks/forms/components/submissions-export.tsx +67 -0
  213. package/blocks/forms/components/submissions-table.tsx +17 -6
  214. package/blocks/forms/lib/forms-extra.ts +67 -0
  215. package/blocks/inbox/README.md +32 -3
  216. package/blocks/inbox/block.json +29 -2
  217. package/blocks/inbox/components/inbox-list.tsx +7 -3
  218. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  219. package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
  220. package/blocks/inbox/components/reply-composer.tsx +65 -9
  221. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  222. package/blocks/inbox/components/thread-badge.tsx +2 -2
  223. package/blocks/inbox/components/thread-view.tsx +1 -1
  224. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  225. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  226. package/blocks/inbox/lib/inbox-types.ts +47 -1
  227. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  228. package/blocks/integrations/README.md +63 -1
  229. package/blocks/integrations/block.json +234 -15
  230. package/blocks/integrations/components/ad-accounts.tsx +82 -0
  231. package/blocks/integrations/components/connection-health.tsx +59 -0
  232. package/blocks/integrations/components/crm-detail.tsx +119 -0
  233. package/blocks/integrations/components/crm-records.tsx +103 -0
  234. package/blocks/integrations/components/field-mappings.tsx +61 -0
  235. package/blocks/integrations/components/integration-list.tsx +13 -8
  236. package/blocks/integrations/components/integrations-screen.tsx +152 -0
  237. package/blocks/integrations/components/migration-panel.tsx +133 -0
  238. package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
  239. package/blocks/integrations/components/sync-status.tsx +11 -9
  240. package/blocks/integrations/components/sync-triggers.tsx +134 -0
  241. package/blocks/integrations/components/webhook-detail.tsx +179 -0
  242. package/blocks/integrations/components/webhook-manager.tsx +43 -25
  243. package/blocks/integrations/components/workspace-tools.tsx +127 -0
  244. package/blocks/integrations/lib/crm-utils.ts +39 -0
  245. package/blocks/integrations/lib/facts.tsx +13 -0
  246. package/blocks/integrations/lib/integrations-utils.ts +11 -1
  247. package/blocks/integrations/lib/load-more.tsx +141 -0
  248. package/blocks/intent/README.md +14 -0
  249. package/blocks/intent/block.json +27 -1
  250. package/blocks/intent/components/account-intent-score.tsx +5 -5
  251. package/blocks/intent/components/intent-feed.tsx +13 -10
  252. package/blocks/intent/components/intent-keyword-manager.tsx +16 -12
  253. package/blocks/intent/components/intent-screen.tsx +265 -0
  254. package/blocks/intent/components/keyword-detail.tsx +124 -0
  255. package/blocks/intent/lib/intent-utils.ts +4 -4
  256. package/blocks/lifecycle/block.json +13 -8
  257. package/blocks/lifecycle/components/account-related.tsx +8 -1
  258. package/blocks/lifecycle/components/churn-list.tsx +5 -2
  259. package/blocks/lifecycle/components/customer-table.tsx +1 -1
  260. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  261. package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
  262. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  263. package/blocks/lists/README.md +20 -1
  264. package/blocks/lists/block.json +69 -3
  265. package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
  266. package/blocks/lists/components/add-to-list-button.tsx +20 -9
  267. package/blocks/lists/components/list-picker.tsx +17 -3
  268. package/blocks/lists/components/list-table.tsx +12 -6
  269. package/blocks/lists/components/lists-screen.tsx +346 -0
  270. package/blocks/lists/components/record-lists-tab.tsx +99 -0
  271. package/blocks/lists/components/suppression-manager.tsx +33 -8
  272. package/blocks/lists/components/suppressions-screen.tsx +264 -0
  273. package/blocks/mailboxes/README.md +17 -0
  274. package/blocks/mailboxes/block.json +69 -17
  275. package/blocks/mailboxes/components/connect-mailbox.tsx +32 -21
  276. package/blocks/mailboxes/components/domain-detail.tsx +148 -0
  277. package/blocks/mailboxes/components/domain-purchase.tsx +20 -14
  278. package/blocks/mailboxes/components/mailbox-detail.tsx +157 -0
  279. package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
  280. package/blocks/mailboxes/components/mailboxes-screen.tsx +228 -0
  281. package/blocks/mailboxes/components/warmup-health.tsx +24 -22
  282. package/blocks/mailboxes/lib/mailboxes-utils.ts +26 -5
  283. package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
  284. package/blocks/newsletter/README.md +12 -0
  285. package/blocks/newsletter/block.json +157 -24
  286. package/blocks/newsletter/components/issue-scheduler.tsx +4 -3
  287. package/blocks/newsletter/components/issue-versions.tsx +93 -0
  288. package/blocks/newsletter/components/more-rows.tsx +30 -0
  289. package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
  290. package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
  291. package/blocks/newsletter/components/newsletter-screen.tsx +39 -14
  292. package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
  293. package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
  294. package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
  295. package/blocks/newsletter/components/subscriber-table.tsx +5 -7
  296. package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
  297. package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
  298. package/blocks/nurture/block.json +28 -14
  299. package/blocks/nurture/components/nurture-builder.tsx +13 -3
  300. package/blocks/nurture/components/nurture-enroll.tsx +12 -10
  301. package/blocks/nurture/components/nurture-form.tsx +27 -10
  302. package/blocks/nurture/components/nurture-screen.tsx +26 -11
  303. package/blocks/nurture/components/nurture-stats.tsx +5 -4
  304. package/blocks/nurture/lib/nurture-types.ts +1 -1
  305. package/blocks/objects/README.md +18 -1
  306. package/blocks/objects/block.json +42 -21
  307. package/blocks/objects/components/attribute-field-input.tsx +14 -1
  308. package/blocks/objects/components/object-not-found.tsx +3 -1
  309. package/blocks/objects/components/object-record-detail.tsx +10 -2
  310. package/blocks/objects/components/object-schema-editor.tsx +4 -2
  311. package/blocks/objects/components/object-table.tsx +13 -3
  312. package/blocks/objects/components/objects-screen.tsx +147 -0
  313. package/blocks/objects/components/record-roles-panel.tsx +15 -5
  314. package/blocks/objects/lib/object-types.ts +1 -1
  315. package/blocks/pages/README.md +14 -0
  316. package/blocks/pages/block.json +140 -23
  317. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  318. package/blocks/pages/components/content-library.tsx +9 -4
  319. package/blocks/pages/components/more-rows.tsx +30 -0
  320. package/blocks/pages/components/page-ai-tools.tsx +196 -0
  321. package/blocks/pages/components/page-analytics.tsx +176 -0
  322. package/blocks/pages/components/page-clone.tsx +99 -0
  323. package/blocks/pages/components/page-domains.tsx +167 -0
  324. package/blocks/pages/components/page-editor.tsx +1 -1
  325. package/blocks/pages/components/page-generation-status.tsx +48 -0
  326. package/blocks/pages/components/page-list.tsx +2 -1
  327. package/blocks/pages/components/page-tracking.tsx +137 -0
  328. package/blocks/pages/components/pages-screen.tsx +57 -16
  329. package/blocks/pages/components/seo-report.tsx +6 -1
  330. package/blocks/pages/lib/studio.ts +7 -0
  331. package/blocks/phone-numbers/README.md +74 -0
  332. package/blocks/phone-numbers/block.json +262 -0
  333. package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
  334. package/blocks/phone-numbers/components/assign-number.tsx +104 -0
  335. package/blocks/phone-numbers/components/number-search.tsx +239 -0
  336. package/blocks/phone-numbers/components/number-settings.tsx +134 -0
  337. package/blocks/phone-numbers/components/number-usage.tsx +97 -0
  338. package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
  339. package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
  340. package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
  341. package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
  342. package/blocks/phone-numbers/components/release-number.tsx +126 -0
  343. package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
  344. package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
  345. package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
  346. package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
  347. package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
  348. package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
  349. package/blocks/pickers/README.md +23 -0
  350. package/blocks/pickers/block.json +103 -0
  351. package/blocks/pickers/components/company-picker.tsx +90 -0
  352. package/blocks/pickers/components/contact-picker.tsx +131 -0
  353. package/blocks/pickers/components/member-picker.tsx +99 -0
  354. package/blocks/pickers/components/search-picker.tsx +98 -0
  355. package/blocks/pickers/hooks/use-current-member.ts +104 -0
  356. package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
  357. package/blocks/pickers/lib/use-debounced.ts +13 -0
  358. package/blocks/quotes/README.md +13 -0
  359. package/blocks/quotes/block.json +65 -13
  360. package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
  361. package/blocks/quotes/components/product-picker.tsx +3 -1
  362. package/blocks/quotes/components/quote-builder.tsx +1 -1
  363. package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
  364. package/blocks/quotes/components/quote-list.tsx +5 -3
  365. package/blocks/quotes/components/quote-related.tsx +8 -4
  366. package/blocks/quotes/components/quote-viewer.tsx +7 -2
  367. package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
  368. package/blocks/radar/README.md +10 -0
  369. package/blocks/radar/block.json +41 -1
  370. package/blocks/radar/components/competitor-battle-card.tsx +1 -1
  371. package/blocks/radar/components/competitor-detail.tsx +117 -0
  372. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  373. package/blocks/radar/components/radar-overview.tsx +4 -4
  374. package/blocks/radar/components/radar-report.tsx +4 -2
  375. package/blocks/radar/components/radar-screen.tsx +214 -0
  376. package/blocks/radar/components/report-detail.tsx +104 -0
  377. package/blocks/search/README.md +10 -0
  378. package/blocks/search/block.json +16 -1
  379. package/blocks/search/components/filter-builder.tsx +3 -3
  380. package/blocks/search/components/prospect-results.tsx +5 -3
  381. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  382. package/blocks/search/components/saved-searches.tsx +4 -1
  383. package/blocks/search/hooks/use-filter-options.ts +20 -6
  384. package/blocks/search/lib/search-types.ts +2 -0
  385. package/blocks/sequencer/README.md +55 -9
  386. package/blocks/sequencer/block.json +126 -19
  387. package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
  388. package/blocks/sequencer/components/enroll-dialog.tsx +11 -5
  389. package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
  390. package/blocks/sequencer/components/sequence-builder.tsx +224 -60
  391. package/blocks/sequencer/components/sequence-detail.tsx +261 -0
  392. package/blocks/sequencer/components/sequence-enrollments.tsx +116 -0
  393. package/blocks/sequencer/components/sequence-list.tsx +34 -9
  394. package/blocks/sequencer/components/sequence-stats.tsx +11 -10
  395. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  396. package/blocks/sequencer/components/sequences-screen.tsx +162 -0
  397. package/blocks/sequencer/components/step-editor.tsx +14 -12
  398. package/blocks/sequencer/lib/sequencer-utils.ts +133 -10
  399. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  400. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  401. package/blocks/social-ads/README.md +19 -0
  402. package/blocks/social-ads/block.json +233 -1
  403. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  404. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  405. package/blocks/social-ads/components/audience-sync-detail.tsx +194 -0
  406. package/blocks/social-ads/components/audience-sync.tsx +17 -9
  407. package/blocks/social-ads/components/campaign-ads.tsx +166 -0
  408. package/blocks/social-ads/components/campaign-detail.tsx +138 -0
  409. package/blocks/social-ads/components/campaign-editor.tsx +134 -0
  410. package/blocks/social-ads/components/creative-concepts.tsx +74 -0
  411. package/blocks/social-ads/components/creative-detail.tsx +155 -0
  412. package/blocks/social-ads/components/creative-gallery.tsx +35 -7
  413. package/blocks/social-ads/components/creative-revisions.tsx +57 -0
  414. package/blocks/social-ads/components/creative-tools.tsx +176 -0
  415. package/blocks/social-ads/components/post-composer.tsx +7 -3
  416. package/blocks/social-ads/components/post-detail.tsx +163 -0
  417. package/blocks/social-ads/components/social-ideas.tsx +64 -0
  418. package/blocks/social-ads/components/social-performance.tsx +80 -0
  419. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  420. package/blocks/social-ads/components/social-screen.tsx +187 -0
  421. package/blocks/social-ads/lib/ads-extra.ts +67 -0
  422. package/blocks/surveys/README.md +91 -0
  423. package/blocks/surveys/block.json +299 -0
  424. package/blocks/surveys/components/account-nps.tsx +69 -0
  425. package/blocks/surveys/components/contact-survey-history.tsx +52 -0
  426. package/blocks/surveys/components/org-summary.tsx +82 -0
  427. package/blocks/surveys/components/sending-domains.tsx +113 -0
  428. package/blocks/surveys/components/survey-form.tsx +126 -0
  429. package/blocks/surveys/components/survey-responses.tsx +145 -0
  430. package/blocks/surveys/components/survey-results.tsx +72 -0
  431. package/blocks/surveys/components/survey-send-history.tsx +58 -0
  432. package/blocks/surveys/components/survey-send.tsx +162 -0
  433. package/blocks/surveys/components/survey-triggers.tsx +166 -0
  434. package/blocks/surveys/components/surveys-screen.tsx +234 -0
  435. package/blocks/surveys/lib/surveys-types.ts +228 -0
  436. package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
  437. package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
  438. package/blocks/surveys/recipes/survey-score-report.ts +27 -0
  439. package/blocks/visitors/README.md +10 -0
  440. package/blocks/visitors/block.json +25 -1
  441. package/blocks/visitors/components/snippet-install.tsx +21 -10
  442. package/blocks/visitors/components/traffic-chart.tsx +4 -4
  443. package/blocks/visitors/components/visitor-company-card.tsx +26 -16
  444. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  445. package/blocks/visitors/components/visitor-feed.tsx +16 -7
  446. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  447. package/blocks/webchat/README.md +10 -0
  448. package/blocks/webchat/block.json +21 -7
  449. package/blocks/webchat/components/chat-widget-config.tsx +8 -5
  450. package/blocks/webchat/components/conversation-list.tsx +8 -4
  451. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  452. package/blocks/workbench/README.md +11 -0
  453. package/blocks/workbench/block.json +48 -1
  454. package/blocks/workbench/components/column-recipe-editor.tsx +5 -3
  455. package/blocks/workbench/components/run-progress.tsx +1 -1
  456. package/blocks/workbench/components/workbench-grid.tsx +10 -4
  457. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  458. package/blocks/workbench/components/workbench-screen.tsx +202 -0
  459. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  460. package/blocks/workbench/components/workbench-table-list.tsx +14 -2
  461. package/blocks/workflows/README.md +13 -0
  462. package/blocks/workflows/block.json +28 -9
  463. package/blocks/workflows/components/run-history.tsx +31 -9
  464. package/blocks/workflows/components/trigger-config.tsx +3 -1
  465. package/blocks/workflows/components/workflow-editor.tsx +4 -2
  466. package/blocks/workflows/components/workflow-list.tsx +13 -6
  467. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  468. package/dist/cli.js +3109 -1977
  469. package/dist/errors.d.mts +34 -0
  470. package/dist/errors.d.ts +34 -0
  471. package/dist/errors.js +46 -0
  472. package/dist/errors.js.map +1 -0
  473. package/dist/errors.mjs +21 -0
  474. package/dist/errors.mjs.map +1 -0
  475. package/dist/index.browser.js +11 -5
  476. package/dist/index.browser.js.map +1 -1
  477. package/dist/index.browser.mjs +11 -5
  478. package/dist/index.browser.mjs.map +1 -1
  479. package/dist/index.d.mts +164 -25
  480. package/dist/index.d.ts +164 -25
  481. package/dist/index.js +11 -5
  482. package/dist/index.js.map +1 -1
  483. package/dist/index.mjs +11 -5
  484. package/dist/index.mjs.map +1 -1
  485. package/dist/react.browser.js +10 -4
  486. package/dist/react.browser.js.map +1 -1
  487. package/dist/react.browser.mjs +10 -4
  488. package/dist/react.browser.mjs.map +1 -1
  489. package/dist/react.d.mts +142 -5
  490. package/dist/react.d.ts +142 -5
  491. package/dist/react.js +10 -4
  492. package/dist/react.js.map +1 -1
  493. package/dist/react.mjs +10 -4
  494. package/dist/react.mjs.map +1 -1
  495. package/dist/types/blocks/add.d.ts +11 -4
  496. package/dist/types/blocks/brand.d.ts +122 -0
  497. package/dist/types/blocks/compose.d.ts +53 -0
  498. package/dist/types/blocks/create.d.ts +27 -20
  499. package/dist/types/blocks/doctor.d.ts +6 -1
  500. package/dist/types/blocks/flows.d.ts +7 -1
  501. package/dist/types/blocks/navfile.d.ts +35 -0
  502. package/dist/types/blocks/orgBrand.d.ts +34 -0
  503. package/dist/types/blocks/paths.d.ts +4 -0
  504. package/dist/types/blocks/prefixes.d.ts +37 -0
  505. package/dist/types/blocks/registry.d.ts +17 -0
  506. package/dist/types/blocks/screens.d.ts +53 -0
  507. package/dist/types/cli.d.ts +5 -0
  508. package/dist/types/errors.d.ts +33 -0
  509. package/dist/types/http.d.ts +2 -18
  510. package/dist/types/version.d.ts +2 -2
  511. package/docs/agents.md +15 -4
  512. package/docs/api/README.md +2 -2
  513. package/docs/api/by-path.md +1 -0
  514. package/docs/api/desk.d.ts +98 -2
  515. package/docs/api/desk.md +123 -1
  516. package/docs/api/inbox.d.ts +10 -2
  517. package/docs/api/inbox.md +10 -2
  518. package/docs/api/seo.d.ts +19 -1
  519. package/docs/api/seo.md +24 -1
  520. package/package.json +16 -2
  521. package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
  522. package/stacks/6sense/pages/contacts.tsx.tmpl +19 -0
  523. package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
  524. package/stacks/6sense.json +55 -17
  525. package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
  526. package/stacks/apollo/pages/companies.tsx.tmpl +33 -0
  527. package/stacks/apollo/pages/contacts.tsx.tmpl +51 -0
  528. package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
  529. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
  530. package/stacks/apollo/pages/lists.tsx.tmpl +12 -0
  531. package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
  532. package/stacks/apollo/pages/sequences.tsx.tmpl +12 -0
  533. package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
  534. package/stacks/apollo.json +147 -37
  535. package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
  536. package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
  537. package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
  538. package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
  539. package/stacks/calendly.json +64 -0
  540. package/stacks/clay/pages/companies.tsx.tmpl +31 -0
  541. package/stacks/clay/pages/contacts.tsx.tmpl +31 -0
  542. package/stacks/clay/pages/lists.tsx.tmpl +12 -0
  543. package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
  544. package/stacks/clay.json +73 -27
  545. package/stacks/gong/pages/calls.tsx.tmpl +23 -0
  546. package/stacks/gong/pages/companies.tsx.tmpl +22 -0
  547. package/stacks/gong/pages/contacts.tsx.tmpl +32 -0
  548. package/stacks/gong/pages/lists.tsx.tmpl +12 -0
  549. package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
  550. package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
  551. package/stacks/gong.json +98 -35
  552. package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
  553. package/stacks/hootsuite/pages/contacts.tsx.tmpl +28 -0
  554. package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
  555. package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
  556. package/stacks/hootsuite.json +104 -0
  557. package/stacks/hubspot/pages/companies.tsx.tmpl +26 -0
  558. package/stacks/hubspot/pages/contacts.tsx.tmpl +45 -0
  559. package/stacks/hubspot/pages/deals.tsx.tmpl +20 -0
  560. package/stacks/hubspot/pages/inbox.tsx.tmpl +103 -0
  561. package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
  562. package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
  563. package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
  564. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  565. package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
  566. package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
  567. package/stacks/hubspot.json +181 -64
  568. package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
  569. package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
  570. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
  571. package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
  572. package/stacks/intercom.json +52 -20
  573. package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
  574. package/stacks/mailchimp/pages/contacts.tsx.tmpl +28 -0
  575. package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
  576. package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
  577. package/stacks/mailchimp.json +68 -34
  578. package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
  579. package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
  580. package/stacks/outreach/pages/contacts.tsx.tmpl +46 -0
  581. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
  582. package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
  583. package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
  584. package/stacks/outreach/pages/sequences.tsx.tmpl +12 -0
  585. package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
  586. package/stacks/outreach.json +106 -25
  587. package/stacks/pipedrive/pages/companies.tsx.tmpl +23 -0
  588. package/stacks/pipedrive/pages/contacts.tsx.tmpl +27 -0
  589. package/stacks/pipedrive/pages/deals.tsx.tmpl +20 -0
  590. package/stacks/pipedrive/pages/inbox.tsx.tmpl +103 -0
  591. package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
  592. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  593. package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
  594. package/stacks/pipedrive.json +117 -0
  595. package/stacks/salesforce/pages/companies.tsx.tmpl +26 -0
  596. package/stacks/salesforce/pages/contacts.tsx.tmpl +32 -0
  597. package/stacks/salesforce/pages/deals.tsx.tmpl +20 -0
  598. package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
  599. package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
  600. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  601. package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
  602. package/stacks/salesforce.json +85 -31
  603. package/stacks/zoominfo/pages/companies.tsx.tmpl +31 -0
  604. package/stacks/zoominfo/pages/contacts.tsx.tmpl +31 -0
  605. package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
  606. package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
  607. package/stacks/zoominfo.json +115 -0
  608. package/styles/styles.css +1 -1
  609. package/templates/next-app/README.md.tmpl +40 -9
  610. package/templates/next-app/app/globals.css.tmpl +349 -64
  611. package/templates/next-app/app/layout.tsx.tmpl +7 -2
  612. package/templates/next-app/components/app-shell.tsx.tmpl +104 -88
  613. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
  614. package/templates/next-app/dot-gitignore.tmpl +2 -0
  615. package/templates/next-app/package.json.tmpl +1 -1
@@ -7,21 +7,31 @@
7
7
  *
8
8
  * SDK operations, in order:
9
9
  * 1. listEnrichmentProviders GET /enrichment/providers (read) provider choices, graph8-funded vs own key
10
+ * 1b. listWaterfallConfigs GET /enrichment/waterfall/configs?list_id (read) the list's own pipeline for the field, if any:
11
+ * a run reuses it with ITS providers, so the quote and the confirm are priced with those, not the chosen ones.
10
12
  * 2. preflightEnrichment POST /enrichment/preflight (read, free; nothing is written or charged)
11
13
  * { list_id, contact_ids, mode: "waterfall", providers, target_field } -> contact_count, not_in_list,
12
14
  * missing_credentials, warnings, credits_per_record, max_total_credits
13
- * 3. startEnrichment POST /enrichment/enrich (billable) 202 { job_id, status }
14
- * Goes through ConfirmSpendDialog first with `max_total_credits` (or 2 credits x contacts when the
15
- * preflight could not price it). There is no balance read, so this is an estimate of the most it can charge.
16
- * 4. getEnrichmentJob GET /enrichment/jobs/{job_id} (read) polled with useJobPoll (no streams), backoff;
15
+ * After ConfirmSpendDialog (with `max_total_credits`, or 2 credits x contacts when the preflight could not price it;
16
+ * there is no balance read, so this is an estimate of the most it can charge):
17
+ * 3. createDefaultWaterfallConfig POST /enrichment/waterfall/configs { list_id, name, field, providers } (billable tier, free)
18
+ * Creates the list's pipeline for that field, or reuses the one it already has (`created: false`; it then runs
19
+ * with ITS providers). The column_id is kept, so a retry after step 4 failed does not create it again.
20
+ * 4. runWaterfallPipeline POST /enrichment/waterfall/enrich { column_id, list_id, record_ids } (billable) 202 { job_id, status }
21
+ * A 202 whose status is "error" (or a 502) means the job never started: shown as a failure, never polled.
22
+ * 5. getEnrichmentJob GET /enrichment/jobs/{job_id} (read) polled with useJobPoll (no streams), backoff;
17
23
  * read once more with ?include_provider_errors=true when nothing was enriched or some records failed.
18
- * 5. cancelEnrichmentJob POST /enrichment/jobs/{job_id}/cancel (write, free; records already done keep their data and charge)
24
+ * 6. cancelEnrichmentJob POST /enrichment/jobs/{job_id}/cancel (write, free; records already done keep their data and charge)
25
+ *
26
+ * When the job ends, every cached `contacts` query is refreshed (and the page mode's ContactTable reloads) so the
27
+ * filled values show. The panel does not use POST /enrichment/enrich (startEnrichment): before the API fix of
28
+ * 2026-10-16 that route dispatched the field name instead of a pipeline and silently enriched nothing.
19
29
  *
20
30
  * The contacts must be on `listId`; ids not on it are skipped and not charged. Locked contacts are enriched
21
31
  * like any other once they sit in a list.
22
32
  */
23
33
 
24
- import { useState } from "react";
34
+ import { useRef, useState } from "react";
25
35
  import { Button } from "@graph8/sdk/ui";
26
36
  import { ApiError } from "@/g8/components/api-error";
27
37
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
@@ -29,54 +39,90 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
29
39
  import { useG8Query } from "@/g8/hooks/use-g8-query";
30
40
  import { useJobPoll } from "@/g8/hooks/use-job-poll";
31
41
  import { g8Get, g8Post } from "@/g8/lib/g8-client";
42
+ import { g8QueryCache } from "@/g8/lib/g8-cache";
32
43
  import { ContactTable } from "@/g8/crm/components/contact-table";
33
44
  import { ListPicker } from "@/g8/lists/components/list-picker";
45
+ import { plural } from "@/g8/lib/plural";
34
46
  import { useProviders } from "../hooks/use-providers";
35
- import { CREDITS_PER_RECORD_ESTIMATE, TARGET_FIELDS, estimateCredits, isJobTerminal } from "../lib/enrichment-types";
36
- import type { EnrichJob, Preflight, TargetField } from "../lib/enrichment-types";
47
+ import { CREDITS_PER_RECORD_ESTIMATE, FIELD_PROVIDERS, TARGET_FIELDS, estimateCredits, isJobTerminal, providersForField } from "../lib/enrichment-types";
48
+ import type { EnrichJob, Preflight, TargetField, WaterfallConfigSummary, WaterfallPipelineResult } from "../lib/enrichment-types";
49
+ import { useBrand } from "@/g8/hooks/use-brand";
37
50
 
38
51
  export interface BulkEnrichPanelProps {
39
52
  /** With `listId` and `contactIds` it is just the enrichment panel. Omit them and it is a whole page: pick a list, tick contacts, enrich. */
40
53
  listId?: number;
41
54
  contactIds?: number[];
42
- /** Providers tried, in order, when the person has not chosen. Default: prospeo, dropcontact (the API's own default). */
55
+ /**
56
+ * Providers tried, in order, when the person has not chosen. Kept to the providers the field supports. Default: the
57
+ * API's own per field (work email: prospeo, dropcontact; phones: leadmagic, prospeo, apollo).
58
+ */
43
59
  defaultProviders?: string[];
44
60
  defaultField?: TargetField;
45
61
  /** Called when the job reached a final state. */
46
62
  onDone?: (job: EnrichJob) => void;
63
+ /** Called with the job id as soon as a run starts (to list it in a job history). */
64
+ onStarted?: (jobId: string) => void;
47
65
  className?: string;
48
66
  }
49
67
 
50
- const DEFAULT_PROVIDERS = ["prospeo", "dropcontact"];
68
+ type StartedRun = { job: EnrichJob; pipeline: WaterfallPipelineResult; asked: string[] };
51
69
 
52
- function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDERS, defaultField = "work_email", onDone, className }: BulkEnrichPanelProps & { listId: number; contactIds: number[] }) {
70
+ function BulkEnrichCore({ listId, contactIds, defaultProviders, defaultField = "work_email", onDone, onStarted, className }: BulkEnrichPanelProps & { listId: number; contactIds: number[] }) {
71
+ const brand = useBrand();
53
72
  const [field, setField] = useState<TargetField>(defaultField);
54
73
  const [chosen, setChosen] = useState<string[] | null>(null);
55
74
  const [confirmOpen, setConfirmOpen] = useState(false);
56
75
  const [jobId, setJobId] = useState<string | null>(null);
76
+ const [run, setRun] = useState<StartedRun | null>(null);
77
+ // The pipeline step 3 created or reused, kept per list/field/providers so a retry does not create it twice.
78
+ const pipelineRef = useRef<{ key: string; pipeline: WaterfallPipelineResult } | null>(null);
57
79
  const catalog = useProviders();
58
80
 
59
81
  const known = new Set(catalog.providers.map((p) => p.provider));
60
- const providers = chosen ?? defaultProviders.filter((p) => known.size === 0 || known.has(p));
82
+ const providers = chosen ?? providersForField(field, defaultProviders).filter((p) => known.size === 0 || known.has(p));
83
+ const offered = catalog.providers.filter((p) => FIELD_PROVIDERS[field].includes(p.provider) && !providers.includes(p.provider));
61
84
  const column = TARGET_FIELDS.find((f) => f.value === field)!;
62
85
  const ids = [...new Set(contactIds)];
63
86
 
87
+ // The list's own pipeline for this field wins over the chosen providers (the API reuses it), so price that one.
88
+ const saved = useG8Query(
89
+ async (signal) => (await g8Get<{ configs: WaterfallConfigSummary[] }>("/enrichment/waterfall/configs", { list_id: listId }, { signal })).data.configs ?? [],
90
+ [listId],
91
+ { enabled: !jobId },
92
+ );
93
+ const existing = saved.data?.find((c) => c.field_to_enrich === column.target && c.providers.length > 0) ?? null;
94
+ const runProviders = existing ? existing.providers : providers;
95
+
64
96
  const preflight = useG8Query(
65
- async (signal) => (await g8Post<Preflight>("/enrichment/preflight", { list_id: listId, contact_ids: ids, mode: "waterfall", providers, target_field: column.target }, { signal })).data,
66
- [listId, ids.join(","), providers.join(","), field],
97
+ async (signal) => (await g8Post<Preflight>("/enrichment/preflight", { list_id: listId, contact_ids: ids, mode: "waterfall", providers: runProviders, target_field: column.target }, { signal })).data,
98
+ [listId, ids.join(","), runProviders.join(","), field],
67
99
  { enabled: ids.length > 0 && !jobId },
68
100
  );
69
101
  const quote = preflight.data;
70
102
  const credits = estimateCredits(quote, ids.length);
71
103
  const runnable = quote ? quote.contact_count - quote.not_in_list.length : ids.length;
72
104
 
73
- 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); },
105
+ const start = useG8Mutation(async (): Promise<StartedRun> => {
106
+ const key = `${listId}|${column.target}|${providers.join(",")}`;
107
+ let pipeline = pipelineRef.current?.key === key ? pipelineRef.current.pipeline : null;
108
+ if (!pipeline) {
109
+ pipeline = (await g8Post<WaterfallPipelineResult>("/enrichment/waterfall/configs", { list_id: listId, name: `${column.label} enrichment`, field: column.target, providers })).data;
110
+ pipelineRef.current = { key, pipeline };
111
+ }
112
+ const job = (await g8Post<EnrichJob>("/enrichment/waterfall/enrich", { column_id: pipeline.column_id, list_id: listId, record_ids: ids })).data;
113
+ if (!job?.job_id || String(job.status).toLowerCase() === "error") throw new Error("The enrichment did not start, so nothing was charged. Try again in a minute.");
114
+ return { job, pipeline, asked: providers };
115
+ }, {
116
+ onSuccess: (res) => { setConfirmOpen(false); setRun(res); setJobId(res.job.job_id); onStarted?.(res.job.job_id); },
75
117
  });
76
118
  const job = useJobPoll<EnrichJob>(
77
119
  async (signal) => (await g8Get<EnrichJob>(`/enrichment/jobs/${jobId}`, undefined, { signal })).data,
78
- { enabled: jobId != null, intervalMs: 2000, isTerminal: (j) => isJobTerminal(j.status), onDone: (j) => onDone?.(j) },
120
+ {
121
+ enabled: jobId != null, intervalMs: 2000, isTerminal: (j) => isJobTerminal(j.status),
122
+ onDone: (j) => { void g8QueryCache.invalidate(["contacts"]); onDone?.(j); },
123
+ },
79
124
  );
125
+ const reused = run && !run.pipeline.created && run.pipeline.providers?.length && run.pipeline.providers.join() !== run.asked.join() ? run.pipeline.providers : null;
80
126
  const cancel = useG8Mutation(() => g8Post(`/enrichment/jobs/${jobId}/cancel`, {}));
81
127
  const finished = job.status;
82
128
  const nothingWritten = job.done && ((finished?.successful_enrichments ?? 0) === 0 || (finished?.failed_enrichments ?? 0) > 0);
@@ -101,9 +147,10 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
101
147
  const pct = total ? Math.round(((j?.completed ?? 0) / total) * 100) : 0;
102
148
  return (
103
149
  <div className={`flex flex-col gap-3 ${className ?? ""}`} role="status">
104
- <h3 className="text-base font-semibold">{job.done ? (j?.status === "completed" ? "Enrichment finished" : `Enrichment ${j?.status}`) : "Enriching..."}</h3>
150
+ <h3 className="text-base font-medium">{job.done ? (j?.status === "completed" ? "Enrichment finished" : `Enrichment ${j?.status}`) : "Enriching..."}</h3>
105
151
  <div className="h-2 overflow-hidden rounded bg-muted" aria-label="Progress"><div className="h-full bg-primary transition-all" style={{ width: `${job.done ? 100 : pct}%` }} /></div>
106
152
  <p className="text-sm">{j?.completed ?? 0} of {total} contacts processed{j?.successful_enrichments != null ? `, ${j.successful_enrichments} enriched, ${j.failed_enrichments ?? 0} failed` : ""}.{j?.total_credits_used != null ? ` ${j.total_credits_used} credits used.` : ""}</p>
153
+ {reused ? <p className="text-xs text-muted-foreground">This list already had a {column.label.toLowerCase()} pipeline, so it ran with its providers: {reused.join(" > ")}.</p> : null}
107
154
  {j?.warnings?.map((w) => <p key={w} className="text-xs text-muted-foreground">{w}</p>)}
108
155
  {job.error ? <ApiError error={job.error} title="Could not read the job" onRetry={() => job.start()} /> : null}
109
156
  {errors.data?.length ? (
@@ -114,7 +161,7 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
114
161
  {cancel.error ? <ApiError error={cancel.error} title="Could not cancel" /> : null}
115
162
  <div className="flex gap-2">
116
163
  {!job.done ? <Button variant="outline" size="sm" loading={cancel.loading} onClick={() => void cancel.mutate(undefined)}>Cancel</Button> : null}
117
- {job.done ? <Button size="sm" variant="outline" onClick={() => { setJobId(null); job.stop(); }}>Enrich more</Button> : null}
164
+ {job.done ? <Button size="sm" variant="outline" onClick={() => { setJobId(null); setRun(null); job.stop(); }}>Enrich more</Button> : null}
118
165
  </div>
119
166
  </div>
120
167
  );
@@ -124,17 +171,21 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
124
171
  <div className={`flex flex-col gap-4 ${className ?? ""}`}>
125
172
  <div className="flex flex-wrap items-center gap-3">
126
173
  <label className="flex items-center gap-2 text-sm">Fill
127
- <select aria-label="Field to fill" className="h-9 rounded-md border bg-background px-2" value={field} onChange={(e) => setField(e.target.value as TargetField)}>
174
+ <select aria-label="Field to fill" className="h-9 rounded-md border bg-background px-2" value={field} onChange={(e) => { setField(e.target.value as TargetField); setChosen(null); }}>
128
175
  {TARGET_FIELDS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
129
176
  </select>
130
177
  </label>
131
- <span className="text-sm text-muted-foreground">for {ids.length.toLocaleString()} contact{ids.length === 1 ? "" : "s"}</span>
178
+ <span className="text-sm text-muted-foreground">for {plural(ids.length, "contact")}</span>
132
179
  </div>
133
180
 
134
181
  <fieldset className="flex flex-col gap-1">
135
182
  <legend className="mb-1 text-sm font-medium">Providers, tried in this order</legend>
136
183
  {catalog.error ? <ApiError error={catalog.error} title="Could not load providers" onRetry={() => void catalog.refetch()} /> : null}
137
- {providers.map((p, i) => (
184
+ {existing ? (
185
+ <p className="text-sm text-muted-foreground" data-testid="saved-pipeline">
186
+ This list already has a {column.label.toLowerCase()} pipeline (&quot;{existing.name}&quot;), so a run uses its providers: {existing.providers.join(" > ")}. Delete it under Pipelines to choose others.
187
+ </p>
188
+ ) : providers.map((p, i) => (
138
189
  <div key={p} className="flex items-center gap-2 text-sm">
139
190
  <span className="w-6 text-muted-foreground">{i + 1}.</span><span className="w-32 font-medium">{p}</span>
140
191
  <Button variant="ghost" size="sm" aria-label={`Move ${p} up`} disabled={i === 0} onClick={() => move(p, -1)}>Up</Button>
@@ -143,7 +194,7 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
143
194
  </div>
144
195
  ))}
145
196
  <div className="flex flex-wrap gap-1">
146
- {catalog.providers.filter((p) => !providers.includes(p.provider)).map((p) => (
197
+ {(existing ? [] : offered).map((p) => (
147
198
  <Button key={p.provider} variant="outline" size="sm" onClick={() => toggleProvider(p.provider)}>Add {p.provider}{p.byok_configured ? " (your key)" : p.system_funded ? "" : " (needs a key)"}</Button>
148
199
  ))}
149
200
  </div>
@@ -154,15 +205,15 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
154
205
  <div className="rounded-md border p-3 text-sm" aria-label="Preflight">
155
206
  <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
207
  <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}
208
+ {quote.missing_credentials.length ? <p role="alert" className="mt-1 text-warning-foreground">No API key for {quote.missing_credentials.join(", ")}: those steps are skipped. Add the keys in {brand.linkLabel} settings.</p> : null}
158
209
  {quote.warnings.map((w) => <p key={w} className="mt-1 text-xs text-muted-foreground">{w}</p>)}
159
210
  </div>
160
211
  ) : null}
161
212
  {start.error ? <ApiError error={start.error} title="Could not start the enrichment" /> : null}
162
213
 
163
214
  <div className="flex justify-end">
164
- <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"}
215
+ <Button disabled={ids.length === 0 || runProviders.length === 0 || preflight.loading || runnable === 0 || Boolean(preflight.error)} onClick={() => setConfirmOpen(true)}>
216
+ Enrich {plural(runnable, "contact")}
166
217
  </Button>
167
218
  </div>
168
219
 
@@ -170,7 +221,7 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
170
221
  open={confirmOpen}
171
222
  tier="billable"
172
223
  title={`Enrich ${runnable.toLocaleString()} contacts: ${column.label.toLowerCase()}`}
173
- description={`Providers in order: ${providers.join(", ")}. The estimate is the most this run can charge; your balance is not checked here.`}
224
+ description={`Providers in order: ${runProviders.join(", ")}${existing ? ` (the list's saved ${column.label.toLowerCase()} pipeline)` : ""}. The estimate is the most this run can charge; your balance is not checked here.`}
174
225
  estimatedCredits={credits}
175
226
  affected={runnable}
176
227
  confirmLabel="Start enrichment"
@@ -189,15 +240,18 @@ function BulkEnrichCore({ listId, contactIds, defaultProviders = DEFAULT_PROVIDE
189
240
  export function BulkEnrichPanel(props: BulkEnrichPanelProps) {
190
241
  const [listId, setListId] = useState<number | undefined>(undefined);
191
242
  const [picked, setPicked] = useState<number[]>([]);
243
+ const [tableVersion, setTableVersion] = useState(0);
192
244
  if (props.listId != null && props.contactIds) return <BulkEnrichCore {...props} listId={props.listId} contactIds={props.contactIds} />;
193
245
  const { listId: _l, contactIds: _c, ...rest } = props;
246
+ // A finished run reloads the table so the filled values show (this also clears the ticked contacts).
247
+ const done = (job: EnrichJob) => { setTableVersion((v) => v + 1); props.onDone?.(job); };
194
248
  return (
195
249
  <div className={`flex flex-col gap-4 ${props.className ?? ""}`}>
196
250
  <ListPicker value={listId} allowCreate={false} onChange={(l) => { setListId(l.id); setPicked([]); }} />
197
251
  {listId != null ? (
198
252
  <>
199
- <ContactTable listId={listId} selectable pageSize={10} onSelectionChange={(ids) => setPicked(ids)} />
200
- <BulkEnrichCore {...rest} className={undefined} listId={listId} contactIds={picked} />
253
+ <ContactTable listId={listId} selectable pageSize={10} refreshKey={tableVersion} onSelectionChange={(ids) => setPicked(ids)} />
254
+ <BulkEnrichCore {...rest} className={undefined} listId={listId} contactIds={picked} onDone={done} />
201
255
  </>
202
256
  ) : <p className="text-sm text-muted-foreground">Choose a list to enrich its contacts.</p>}
203
257
  </div>
@@ -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,23 +38,37 @@ 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
 
45
64
  const STYLE: Record<VerifyState, string> = {
46
65
  unverified: "bg-muted text-muted-foreground",
47
- valid: "bg-emerald-100 text-emerald-800",
48
- risky: "bg-amber-100 text-amber-800",
49
- invalid: "bg-red-100 text-red-800",
66
+ valid: "bg-positive text-positive-foreground",
67
+ risky: "bg-warning text-warning-foreground",
68
+ invalid: "bg-error text-error-foreground",
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-warning-foreground">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>
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ /**
4
+ * EnrichmentJobDetail: one bulk enrichment job in full. Live status (polled with backoff, there are no streams),
5
+ * progress, how many records were enriched or failed, credits used, warnings, the provider errors that explain a
6
+ * failure, and Cancel while it runs.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. getEnrichmentJob GET /enrichment/jobs/{job_id} (read) polled with useJobPoll until a final status;
10
+ * read once more with ?include_provider_errors=true when nothing was enriched or some records failed
11
+ * -> { status, total, completed, successful_enrichments, failed_enrichments, total_credits_used, warnings, provider_errors }
12
+ * 2. cancelEnrichmentJob POST /enrichment/jobs/{job_id}/cancel (write, free; records already done keep their data and charge)
13
+ */
14
+
15
+ import { Button } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
18
+ import { SectionCard } from "@/g8/components/screen/section-card";
19
+ import { StatusPill, humanizeStatus, toneForStatus } from "@/g8/components/screen/status-pill";
20
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
21
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
22
+ import { useJobPoll } from "@/g8/hooks/use-job-poll";
23
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
24
+ import { isJobTerminal } from "../lib/enrichment-types";
25
+ import type { EnrichJob } from "../lib/enrichment-types";
26
+
27
+ export interface EnrichmentJobDetailProps {
28
+ jobId: string;
29
+ /** Called when the job reached a final state. */
30
+ onDone?: (job: EnrichJob) => void;
31
+ className?: string;
32
+ }
33
+
34
+ export function EnrichmentJobDetail({ jobId, onDone, className }: EnrichmentJobDetailProps) {
35
+ const job = useJobPoll<EnrichJob>(
36
+ async (signal) => (await g8Get<EnrichJob>(`/enrichment/jobs/${jobId}`, undefined, { signal })).data,
37
+ { intervalMs: 2000, isTerminal: (j) => isJobTerminal(j.status), onDone },
38
+ );
39
+ const cancel = useG8Mutation(() => g8Post(`/enrichment/jobs/${jobId}/cancel`, {}));
40
+ const j = job.status;
41
+ const problems = job.done && j != null && ((j.successful_enrichments ?? 0) === 0 || (j.failed_enrichments ?? 0) > 0);
42
+ const errors = useG8Query(
43
+ async (signal) => (await g8Get<EnrichJob>(`/enrichment/jobs/${jobId}`, { include_provider_errors: true }, { signal })).data.provider_errors ?? [],
44
+ [jobId, job.done],
45
+ { enabled: problems },
46
+ );
47
+ const total = j?.total ?? 0;
48
+ const pct = total ? Math.min(100, Math.round(((j?.completed ?? 0) / total) * 100)) : 0;
49
+
50
+ if (job.error && !j) return <ApiError error={job.error} title="Could not read the job" onRetry={() => job.start()} />;
51
+ return (
52
+ <div className={`flex flex-col gap-4 ${className ?? ""}`} role="status">
53
+ <div className="flex items-center gap-2">
54
+ {j ? <StatusPill tone={toneForStatus(j.status)} dot>{humanizeStatus(j.status)}</StatusPill> : <span className="text-sm text-muted-foreground">Loading...</span>}
55
+ {!job.done && j ? <Button size="sm" variant="outline" loading={cancel.loading} onClick={() => void cancel.mutate(undefined)}>Cancel job</Button> : null}
56
+ </div>
57
+ <div className="h-2 overflow-hidden rounded bg-muted" aria-label="Progress"><div className="h-full bg-primary" style={{ width: `${job.done ? 100 : pct}%` }} /></div>
58
+ {cancel.error ? <ApiError error={cancel.error} title="Could not cancel" /> : null}
59
+ <SectionCard title="Result">
60
+ <KeyValueList columns={2} items={[
61
+ { label: "Job id", value: jobId, mono: true, wide: true },
62
+ { label: "Processed", value: j ? `${(j.completed ?? 0).toLocaleString()} of ${total.toLocaleString()}` : "" },
63
+ { label: "Enriched", value: j?.successful_enrichments != null ? j.successful_enrichments.toLocaleString() : "" },
64
+ { label: "Failed", value: j?.failed_enrichments != null ? j.failed_enrichments.toLocaleString() : "" },
65
+ { label: "Credits used", value: j?.total_credits_used != null ? j.total_credits_used.toLocaleString() : "" },
66
+ ]} />
67
+ </SectionCard>
68
+ {j?.warnings?.length ? <SectionCard title="Warnings"><ul className="list-disc pl-5 text-sm">{j.warnings.map((w) => <li key={w}>{w}</li>)}</ul></SectionCard> : null}
69
+ {errors.data?.length ? (
70
+ <SectionCard title="Provider errors" description="Usually a missing or invalid provider key.">
71
+ <ul className="list-disc pl-5 text-sm text-destructive" aria-label="Provider errors">
72
+ {errors.data.slice(0, 20).map((e, i) => <li key={i}>{e.provider}: {e.error}</li>)}
73
+ </ul>
74
+ </SectionCard>
75
+ ) : null}
76
+ </div>
77
+ );
78
+ }