@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,359 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ContactsScreen: the finished contacts workspace. A searchable, filterable, sortable, paginated grid on the left;
5
+ * the open contact (timeline, notes, tasks, fields) beside it. No props are required.
6
+ *
7
+ * Composes: ScreenLayout, StatStrip, Toolbar, SplitView, DetailPanel, FormSheet (screen kit) with RecordForm,
8
+ * ActivityTimeline, NotesPanel, TaskList, ImportWizard and DedupeReview (this block).
9
+ *
10
+ * Flows: search and filter (name, title, seniority, country; debounced, latest request wins) - sort the loaded
11
+ * page - paginate - open a contact (deep link ?id=, full page with &view=full) - create a contact (a 409 opens the existing one) - edit - delete one
12
+ * or many (confirm, "N of M" report, 404 = already gone) - import a CSV/Excel file - review and merge duplicates -
13
+ * add notes and tasks - hand a contact to other blocks through the slot props.
14
+ *
15
+ * SDK operations: none of its own. listContacts, getContact, getActivityFeed, duplicateCounts are `read`;
16
+ * the forms, notes and tasks are `write`; deleteContact (rows, detail and bulk) is `destructive` and always goes
17
+ * through ConfirmSpendDialog. Nothing here sends a message to a person.
18
+ *
19
+ * Locked (masked) emails and phones show as Locked: save the contact to a list or enrich it to reveal them.
20
+ */
21
+
22
+ import { useEffect, useMemo, useState } from "react";
23
+ import type { ReactNode } from "react";
24
+ import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
25
+ import { Building2, GitMerge, Lock, Plus, Trash2, Upload, Users } from "lucide-react";
26
+ import { ApiError } from "@/g8/components/api-error";
27
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
28
+ import { EmptyState } from "@/g8/components/empty-state";
29
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
30
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
31
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
32
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
33
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
34
+ import { SplitView } from "@/g8/components/screen/split-view";
35
+ import { StatusPill } from "@/g8/components/screen/status-pill";
36
+ import { Toolbar } from "@/g8/components/screen/toolbar";
37
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
38
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
39
+ import { RecordLink } from "@/g8/components/screen/record-link";
40
+ import { plural } from "@/g8/lib/plural";
41
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
42
+ import { useUrlState } from "@/g8/hooks/use-url-state";
43
+ import { g8Delete, g8Get } from "@/g8/lib/g8-client";
44
+ import { useDebouncedValue } from "../hooks/use-debounced";
45
+ import { usePageQuery } from "../hooks/use-page-query";
46
+ import { contactName, errorStatus, isMasked, location } from "../lib/crm-types";
47
+ import type { ContactRecord, ScreenActionContext } from "../lib/crm-types";
48
+ import { Pager, SortHead, formatDay, sortRows, useBulkDelete, useRowSelection, useSort } from "../lib/screen-parts";
49
+ import { ActivityTimeline } from "./activity-timeline";
50
+ import { DedupeReview } from "./dedupe-review";
51
+ import { ImportWizard } from "./import-wizard";
52
+ import { NotesPanel } from "./notes-panel";
53
+ import { RecordForm } from "./record-form";
54
+ import { TaskList } from "./task-list";
55
+
56
+ export interface ContactsScreenProps {
57
+ /** Only members of this list. */
58
+ listId?: number;
59
+ pageSize?: number;
60
+ /** Buttons at the end of each row (enrich, add to list...). */
61
+ renderRowActions?: (row: ContactRecord, ctx: ScreenActionContext) => ReactNode;
62
+ /** Buttons in the open contact's header. */
63
+ renderDetailActions?: (contact: ContactRecord, ctx: ScreenActionContext) => ReactNode;
64
+ /** Extra tabs on the open contact. */
65
+ extraDetailTabs?: Array<{ id: string; label: string; render: (contact: ContactRecord) => ReactNode }>;
66
+ /** Extra buttons in the page header, before "New contact". */
67
+ toolbarActions?: ReactNode;
68
+ /** Extra bulk actions next to Delete (add to list, enrich, enroll...). */
69
+ renderBulkActions?: (selection: { ids: number[]; rows: ContactRecord[]; clear: () => void }) => ReactNode;
70
+ /** Called with a company id from the contact panel ("Open company"). Without it the button is hidden. */
71
+ onOpenCompany?: (companyId: number) => void;
72
+ className?: string;
73
+ }
74
+
75
+ interface Filters { name: string; job_title: string; seniority_level: string; country: string }
76
+ const NO_FILTERS: Filters = { name: "", job_title: "", seniority_level: "", country: "" };
77
+ const SENIORITY = ["C-Suite", "VP", "Director", "Manager"];
78
+
79
+ function sortValue(row: ContactRecord, key: string): string | null | undefined {
80
+ if (key === "name") return contactName(row);
81
+ if (key === "title") return row.job_title;
82
+ if (key === "company") return row.company_name;
83
+ if (key === "location") return location(row);
84
+ return null;
85
+ }
86
+
87
+ function Locked() {
88
+ return <span className="inline-flex items-center gap-1 italic text-muted-foreground" title="Locked: save to a list or enrich to reveal"><Lock className="size-3" aria-hidden />Locked</span>;
89
+ }
90
+ function Cell({ value }: { value?: string | null }) {
91
+ return isMasked(value) ? <Locked /> : <>{value || <span className="text-muted-foreground">-</span>}</>;
92
+ }
93
+
94
+ export function ContactsScreen(props: ContactsScreenProps) {
95
+ const { listId, pageSize = 25 } = props;
96
+ const [idParam, setIdParamRaw] = useUrlState("id");
97
+ const [view, setView] = useUrlState("view");
98
+ const full = view === "full" && idParam != null && idParam !== "";
99
+ /** Changing or clearing the open record also leaves the full-page view. */
100
+ const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
101
+ const parsedId = idParam ? Number(idParam) : null;
102
+ /** A malformed deep link (`?id=abc`) opens nothing instead of fetching /contacts/NaN. */
103
+ const openId = parsedId != null && Number.isFinite(parsedId) ? parsedId : null;
104
+ const [draft, setDraft] = useState<Filters>(NO_FILTERS);
105
+ const filters = useDebouncedValue(draft);
106
+ const [sheet, setSheet] = useState<"new" | "import" | "dupes" | null>(null);
107
+ const [confirmDelete, setConfirmDelete] = useState(false);
108
+ const [note, setNote] = useState<string | null>(null);
109
+ const { sort, toggle: toggleSort } = useSort();
110
+
111
+ const list = usePageQuery<ContactRecord>(
112
+ (page, signal) => g8Get<ContactRecord[]>("/contacts", { page, limit: pageSize, list_id: listId, ...Object.fromEntries(Object.entries(filters).filter(([, v]) => v)) }, { signal }),
113
+ [listId, filters],
114
+ pageSize,
115
+ ["contacts", "list", "screen"],
116
+ );
117
+ const dupes = useG8Query((signal) => g8Get<{ pending?: number; count?: number }>("/duplicates/counts", { entity_type: "contact" }, { signal }), [], { key: ["duplicates", "counts", "contact"] });
118
+ const selection = useRowSelection<ContactRecord>(`${listId}|${JSON.stringify(filters)}`);
119
+ const rows = useMemo(() => sortRows(list.rows, sort, sortValue), [list.rows, sort]);
120
+ const hasFilters = Object.values(draft).some(Boolean);
121
+ const pending = dupes.data?.data.pending ?? dupes.data?.data.count;
122
+ const lockedOnPage = list.rows.filter((r) => isMasked(r.work_email)).length;
123
+
124
+ const bulk = useBulkDelete("/contacts", (gone, total) => {
125
+ selection.drop(gone);
126
+ setConfirmDelete(false);
127
+ setNote(gone.length < total ? `Deleted ${gone.length} of ${total}. ${total - gone.length} could not be deleted; they are still selected.` : null);
128
+ if (openId != null && gone.includes(openId)) setIdParam(null);
129
+ void list.refetch();
130
+ void dupes.refetch();
131
+ });
132
+ const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
133
+ const reload = () => { void list.refetch(); void dupes.refetch(); };
134
+ const set = (key: keyof Filters, v: string) => setDraft((f) => ({ ...f, [key]: v }));
135
+
136
+ useEffect(() => { setNote(null); }, [filters, listId]);
137
+
138
+ return (
139
+ <ScreenLayout
140
+ className={props.className}
141
+ title="Contacts"
142
+ description="Everyone you sell to, with their history, notes and follow-ups."
143
+ icon={<Users className="size-5" aria-hidden />}
144
+ actions={
145
+ <>
146
+ {props.toolbarActions}
147
+ <Button variant="outline" size="sm" onClick={() => setSheet("dupes")}><GitMerge className="size-4" aria-hidden />Duplicates{pending ? ` (${pending})` : ""}</Button>
148
+ <Button variant="outline" size="sm" onClick={() => setSheet("import")}><Upload className="size-4" aria-hidden />Import</Button>
149
+ <Button size="sm" onClick={() => setSheet("new")}><Plus className="size-4" aria-hidden />New contact</Button>
150
+ </>
151
+ }
152
+ stats={[
153
+ { label: "Contacts", value: list.loading && list.total === 0 ? "-" : list.total.toLocaleString(), hint: hasFilters ? "Matching the filters" : listId ? "In this list" : "In your workspace" },
154
+ { label: "Possible duplicates", value: pending ?? "-", tone: pending ? "warning" : "neutral", hint: "Review and merge" },
155
+ { label: "Locked on this page", value: lockedOnPage, hint: "Save to a list or enrich to reveal" },
156
+ ]}
157
+ >
158
+ <SplitView
159
+ detailOpen={openId != null}
160
+ expanded={full}
161
+ onExpandedChange={(e) => setView(e ? "full" : null)}
162
+ onCloseDetail={() => setIdParam(null)}
163
+ detailWidth="lg"
164
+ list={
165
+ <div className="flex flex-col gap-3">
166
+ <Toolbar
167
+ search={{ value: draft.name, onChange: (v) => set("name", v), placeholder: "Search contacts by name" }}
168
+ filters={
169
+ <>
170
+ <input aria-label="Filter by job title" placeholder="Job title" className="h-9 w-36 rounded-md border bg-background px-3 text-sm" value={draft.job_title} onChange={(e) => set("job_title", e.target.value)} />
171
+ <select aria-label="Filter by seniority" className="h-9 rounded-md border bg-background px-2 text-sm" value={draft.seniority_level} onChange={(e) => set("seniority_level", e.target.value)}>
172
+ <option value="">Any seniority</option>
173
+ {SENIORITY.map((s) => <option key={s} value={s}>{s}</option>)}
174
+ </select>
175
+ <input aria-label="Filter by country" placeholder="Country" className="h-9 w-32 rounded-md border bg-background px-3 text-sm" value={draft.country} onChange={(e) => set("country", e.target.value)} />
176
+ {hasFilters ? <Button variant="ghost" size="sm" onClick={() => setDraft(NO_FILTERS)}>Clear filters</Button> : null}
177
+ </>
178
+ }
179
+ />
180
+
181
+ {selection.ids.length > 0 ? (
182
+ <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm" role="status">
183
+ <span className="tabular-nums">{selection.ids.length} selected</span>
184
+ {props.renderBulkActions?.({ ids: selection.ids, rows: selection.rows, clear: selection.clear })}
185
+ <Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}><Trash2 className="size-4" aria-hidden />Delete {selection.ids.length}</Button>
186
+ <Button variant="ghost" size="sm" onClick={selection.clear}>Clear selection</Button>
187
+ </div>
188
+ ) : null}
189
+ {list.error ? <ApiError error={list.error} title="Could not load contacts" onRetry={() => void list.refetch()} /> : null}
190
+ {note ? <p role="alert" className="text-sm text-destructive">{note}</p> : null}
191
+
192
+ <div className="overflow-hidden rounded-md border">
193
+ <Table>
194
+ <TableHeader>
195
+ <TableRow>
196
+ <TableHead className="w-8"><input type="checkbox" aria-label="Select page" checked={allOnPage} onChange={() => selection.togglePage(rows)} /></TableHead>
197
+ <SortHead label="Name" sortKey="name" sort={sort} onSort={toggleSort} />
198
+ <SortHead label="Title" sortKey="title" sort={sort} onSort={toggleSort} />
199
+ <SortHead label="Company" sortKey="company" sort={sort} onSort={toggleSort} />
200
+ <TableHead>Email</TableHead>
201
+ <SortHead label="Location" sortKey="location" sort={sort} onSort={toggleSort} />
202
+ {props.renderRowActions ? <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead> : null}
203
+ </TableRow>
204
+ </TableHeader>
205
+ <TableBody>
206
+ {list.loading && rows.length === 0
207
+ ? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={6 + (props.renderRowActions ? 1 : 0)}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
208
+ : rows.map((row) => {
209
+ const name = contactName(row);
210
+ return (
211
+ <TableRow key={row.id} data-testid={`contact-row-${row.id}`} data-state={row.id === openId ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => setIdParam(String(row.id)))}>
212
+ <TableCell onClick={(e) => e.stopPropagation()}>
213
+ <input type="checkbox" aria-label={`Select ${name}`} checked={selection.selected.has(row.id)} onChange={() => selection.toggle(row)} />
214
+ </TableCell>
215
+ <TableCell>
216
+ <span className="flex items-center gap-2 font-medium"><EntityAvatar name={name} kind="person" size="sm" /><RecordLink param="id" id={row.id} onOpen={() => setIdParam(String(row.id))}>{name}</RecordLink></span>
217
+ </TableCell>
218
+ <TableCell><Cell value={row.job_title} /></TableCell>
219
+ <TableCell><Cell value={row.company_name} /></TableCell>
220
+ <TableCell><Cell value={row.work_email} /></TableCell>
221
+ <TableCell><Cell value={location(row)} /></TableCell>
222
+ {props.renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{props.renderRowActions(row, { reload })}</TableCell> : null}
223
+ </TableRow>
224
+ );
225
+ })}
226
+ </TableBody>
227
+ </Table>
228
+ {!list.loading && !list.error && rows.length === 0 ? (
229
+ <EmptyState
230
+ title={hasFilters ? "No contacts match these filters" : "No contacts yet"}
231
+ description={hasFilters ? "Try fewer filters." : "Add your first contact or import a file."}
232
+ action={hasFilters ? <Button variant="outline" onClick={() => setDraft(NO_FILTERS)}>Clear filters</Button> : <Button onClick={() => setSheet("new")}>New contact</Button>}
233
+ />
234
+ ) : null}
235
+ <Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="contacts" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
236
+ </div>
237
+ {sort ? <p className="text-xs text-muted-foreground">Sorted within this page only.</p> : null}
238
+ </div>
239
+ }
240
+ detail={openId != null ? (
241
+ <ContactPanel key={openId} full={full} setFull={(v) => setView(v ? "full" : null)} contactId={openId} seed={list.rows.find((r) => r.id === openId)} onClose={() => setIdParam(null)} onChanged={reload} onDeleted={() => { setIdParam(null); reload(); }}
242
+ onOpenCompany={props.onOpenCompany} renderActions={props.renderDetailActions} extraTabs={props.extraDetailTabs} />
243
+ ) : null}
244
+ />
245
+
246
+ <FormSheet open={sheet === "new"} onOpenChange={(o) => setSheet(o ? "new" : null)} title="New contact" description="A name, an email or a LinkedIn URL is enough.">
247
+ <RecordForm entity="contact" listId={listId} onCancel={() => setSheet(null)}
248
+ onSaved={({ id }) => { setSheet(null); reload(); if (id != null) setIdParam(String(id)); }}
249
+ onDuplicate={(existing) => { setSheet(null); if (existing != null) setIdParam(String(existing)); }} />
250
+ </FormSheet>
251
+ <FormSheet open={sheet === "import"} onOpenChange={(o) => setSheet(o ? "import" : null)} title="Import contacts" description="Upload a CSV or Excel file, map the columns and choose where they go.">
252
+ <ImportWizard listId={listId} onImported={reload} />
253
+ </FormSheet>
254
+ <FormSheet open={sheet === "dupes"} onOpenChange={(o) => setSheet(o ? "dupes" : null)} title="Duplicate contacts" description="Merge pairs, or mark them as not duplicates.">
255
+ <DedupeReview entity="contact" onChanged={reload} />
256
+ </FormSheet>
257
+
258
+ <ConfirmSpendDialog
259
+ open={confirmDelete}
260
+ tier="destructive"
261
+ title={`Delete ${plural(selection.ids.length, "contact")}`}
262
+ description="They are removed from every list and sequence."
263
+ affected={selection.ids.length}
264
+ confirming={bulk.loading}
265
+ onConfirm={() => void bulk.mutate(selection.ids)}
266
+ onCancel={() => setConfirmDelete(false)}
267
+ />
268
+ </ScreenLayout>
269
+ );
270
+ }
271
+
272
+ interface ContactPanelProps {
273
+ full: boolean;
274
+ setFull: (full: boolean) => void;
275
+ contactId: number;
276
+ /** The list row, for fields the detail answer lacks (owner). */
277
+ seed?: ContactRecord;
278
+ onClose: () => void;
279
+ onChanged: () => void;
280
+ onDeleted: () => void;
281
+ onOpenCompany?: (companyId: number) => void;
282
+ renderActions?: ContactsScreenProps["renderDetailActions"];
283
+ extraTabs?: ContactsScreenProps["extraDetailTabs"];
284
+ }
285
+
286
+ function ContactPanel({ full, setFull, contactId, seed, onClose, onChanged, onDeleted, onOpenCompany, renderActions, extraTabs = [] }: ContactPanelProps) {
287
+ const [editing, setEditing] = useState(false);
288
+ const [confirm, setConfirm] = useState(false);
289
+ const [feedKey, setFeedKey] = useState(0);
290
+ const query = useG8Query((signal) => g8Get<ContactRecord>(`/contacts/${contactId}`, { include_custom_fields: true }, { signal }), [contactId], {
291
+ key: ["contacts", "detail", contactId],
292
+ // The row the contact was opened from paints the panel at once; the full record replaces it.
293
+ placeholderData: seed ? { data: seed } : undefined,
294
+ });
295
+ /** Edit, delete and slot actions wait for the full record: a list row must never seed the edit form. */
296
+ const partial = query.isPlaceholderData === true;
297
+ const remove = useG8Mutation(async () => {
298
+ try { await g8Delete(`/contacts/${contactId}`); } catch (e) { if (errorStatus(e) !== 404) throw e; }
299
+ }, { invalidates: [["contacts", "list"]], onSuccess: () => { setConfirm(false); onDeleted(); } });
300
+ const bump = () => setFeedKey((k) => k + 1);
301
+
302
+ if (query.loading && !query.data) return <div className="flex flex-col gap-3 p-4"><Skeleton className="h-14 w-full" /><Skeleton className="h-40 w-full" /></div>;
303
+ // A failed full record must not leave the list row on screen with every action disabled.
304
+ if (query.error && (!query.data || partial)) return <div className="p-4"><ApiError error={query.error} title="Could not load this contact" onRetry={() => void query.refetch()} /></div>;
305
+ const contact = query.data!.data;
306
+ const name = contactName(contact);
307
+ const val = (v?: string | null): ReactNode => (isMasked(v) ? <Locked /> : v || <span className="text-muted-foreground">-</span>);
308
+
309
+ const overview = (
310
+ <KeyValueList columns={2} items={[
311
+ { label: "Work email", value: val(contact.work_email) },
312
+ { label: "Direct phone", value: val(contact.direct_phone) },
313
+ { label: "Mobile phone", value: val(contact.mobile_phone) },
314
+ { label: "LinkedIn", value: val(contact.linkedin_url) },
315
+ { label: "Seniority", value: val(contact.seniority_level) },
316
+ { label: "Department", value: val(contact.job_department) },
317
+ { label: "Location", value: val(location(contact)) },
318
+ { label: "Owner", value: val(contact.owner_name ?? seed?.owner_name) },
319
+ { label: "Added", value: val(formatDay(contact.created_at)) },
320
+ ...Object.entries(contact.custom_fields ?? {}).map(([k, v]) => ({ label: k, value: val(v) })),
321
+ ...(contact.about ? [{ label: "About", value: contact.about }] : []),
322
+ ]} />
323
+ );
324
+
325
+ return (
326
+ <>
327
+ <DetailPanel
328
+ title={name}
329
+ subtitle={[contact.job_title, contact.company?.name ?? contact.company_name].filter(Boolean).join(" at ") || "No title or company"}
330
+ avatar={<EntityAvatar name={name} kind="person" size="lg" />}
331
+ badges={isMasked(contact.work_email) ? <StatusPill tone="warning" dot>Locked email</StatusPill> : undefined}
332
+ onClose={onClose}
333
+ onExpand={full ? undefined : () => setFull(true)}
334
+ onCollapse={full ? () => setFull(false) : undefined}
335
+ actions={
336
+ <>
337
+ {partial ? null : renderActions?.(contact, { reload: () => { void query.refetch(); bump(); onChanged(); } })}
338
+ {contact.company?.id && onOpenCompany ? <Button variant="outline" size="sm" onClick={() => onOpenCompany(contact.company!.id as number)}><Building2 className="size-4" aria-hidden />Open company</Button> : null}
339
+ <Button variant="outline" size="sm" disabled={partial} title={partial ? "Loading the full record" : undefined} onClick={() => setEditing(true)}>Edit</Button>
340
+ <Button variant="destructive" size="sm" disabled={partial} onClick={() => setConfirm(true)}>Delete</Button>
341
+ </>
342
+ }
343
+ tabs={[
344
+ { id: "overview", label: "Overview", content: overview },
345
+ { id: "activity", label: "Activity", content: <ActivityTimeline entityType="contact" entityId={contactId} refreshKey={feedKey} /> },
346
+ { id: "notes", label: "Notes", content: <NotesPanel entity="contact" entityId={contactId} onChanged={bump} /> },
347
+ { id: "tasks", label: "Tasks", content: <TaskList entity="contact" entityId={contactId} onChanged={bump} /> },
348
+ ...(partial ? [] : extraTabs).map((t) => ({ id: t.id, label: t.label, content: t.render(contact) })),
349
+ ]}
350
+ />
351
+ {remove.error ? <div className="p-4"><ApiError error={remove.error} title="Could not delete the contact" /></div> : null}
352
+ <FormSheet open={editing && !partial} onOpenChange={setEditing} title={`Edit ${name}`}>
353
+ <RecordForm entity="contact" record={contact} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); void query.refetch(); bump(); onChanged(); }} />
354
+ </FormSheet>
355
+ <ConfirmSpendDialog open={confirm} tier="destructive" title={`Delete ${name}`} description="The contact is removed from every list and sequence." affected={1}
356
+ confirming={remove.loading} onConfirm={() => void remove.mutate(undefined)} onCancel={() => setConfirm(false)} />
357
+ </>
358
+ );
359
+ }
@@ -29,6 +29,7 @@ import { useJobPoll } from "@/g8/hooks/use-job-poll";
29
29
  import { usePaginated } from "@/g8/hooks/use-paginated";
30
30
  import { g8Get, g8Post } from "@/g8/lib/g8-client";
31
31
  import { isMasked } from "../lib/crm-types";
32
+ import { useBrand } from "@/g8/hooks/use-brand";
32
33
 
33
34
  export interface DedupeReviewProps {
34
35
  entity: "contact" | "company";
@@ -49,10 +50,11 @@ const CRITERIA = [
49
50
  function side(pair: Pair, s: "a" | "b", entity: "contact" | "company") {
50
51
  const name = entity === "contact" ? [pair[`${s}_first_name`], pair[`${s}_last_name`]].filter(Boolean).join(" ") : String(pair[`${s}_name`] ?? "");
51
52
  const lines = entity === "contact" ? [pair[`${s}_email`], pair[`${s}_phone`], pair[`${s}_owner_name`]] : [pair[`${s}_domain`], pair[`${s}_industry`], pair[`${s}_owner_name`]];
52
- return { id: pair[`${s}_id`] as number, name: name || "Unnamed", lines: lines.filter(Boolean).map((l) => (isMasked(l) ? "Locked" : String(l))) };
53
+ return { id: pair[`${s}_id`] as number, name: name || String([pair[`${s}_email`], pair[`${s}_domain`]].find((v) => v && !isMasked(v)) ?? `#${pair[`${s}_id`]}`), lines: lines.filter(Boolean).map((l) => (isMasked(l) ? "Locked" : String(l))) };
53
54
  }
54
55
 
55
56
  export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps) {
57
+ const brand = useBrand();
56
58
  const plural = entity === "contact" ? "contacts" : "companies";
57
59
  const [merging, setMerging] = useState<{ pair: Pair; keep: "a" | "b" } | null>(null);
58
60
  const [picked, setPicked] = useState<Set<string>>(new Set());
@@ -73,9 +75,9 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
73
75
  const merge = useG8Mutation((v: { pair: Pair; keep: "a" | "b" }) => {
74
76
  const keepId = v.pair[`${v.keep}_id`], dropId = v.pair[`${v.keep === "a" ? "b" : "a"}_id`];
75
77
  return g8Post(`/${plural}/${keepId}/merge`, { secondary_id: dropId, suggestion_id: v.pair.suggestion_id });
76
- }, { onSuccess: (_r, v) => { dropPick(v.pair.suggestion_id); setMerging(null); refreshAll(); } });
77
- const reject = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/${id}/reject`, {}); }, { onSuccess: (_r, id) => { dropPick(id); setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
78
- const undo = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/undo-merge/${id}`, {}); }, { onSuccess: () => { setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
78
+ }, { invalidates: [plural, "duplicates"], onSuccess: (_r, v) => { dropPick(v.pair.suggestion_id); setMerging(null); refreshAll(); } });
79
+ const reject = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/${id}/reject`, {}); }, { invalidates: ["duplicates"], onSuccess: (_r, id) => { dropPick(id); setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
80
+ const undo = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/undo-merge/${id}`, {}); }, { invalidates: [plural, "duplicates"], onSuccess: () => { setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
79
81
  const bulk = useG8Mutation(() => g8Post<{ run_id: string }>("/duplicates/bulk-merge", { suggestion_ids: [...picked], criterion }), {
80
82
  onSuccess: (r) => { setBulkOpen(false); setRunId(r.data.run_id); },
81
83
  });
@@ -109,7 +111,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
109
111
  {run.polling || run.status ? <p role="status" className="text-sm text-muted-foreground">Merging: {run.status?.merged ?? 0} of {run.status?.total ?? picked.size} done, {run.status?.blocked ?? 0} blocked.</p> : null}
110
112
  {mergeError ? <ApiError error={mergeError} title="That did not go through" /> : null}
111
113
  {pairs.error ? <ApiError error={pairs.error} title="Could not load duplicates" onRetry={() => void pairs.reload()} /> : null}
112
- {!pairs.loading && !pairs.error && pairs.items.length === 0 ? <EmptyState title="No duplicates to review" description="New suggestions appear here when graph8 finds likely matches." /> : null}
114
+ {!pairs.loading && !pairs.error && pairs.items.length === 0 ? <EmptyState title="No duplicates to review" description={`New suggestions appear here when ${brand.linkLabel} finds likely matches.`} /> : null}
113
115
 
114
116
  {pairs.items.map((pair) => (
115
117
  <div key={pair.suggestion_id} className="rounded-md border p-3" data-testid="duplicate-pair">
@@ -24,6 +24,7 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
24
24
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
25
25
  import { useG8Query } from "@/g8/hooks/use-g8-query";
26
26
  import { g8Get, g8Post } from "@/g8/lib/g8-client";
27
+ import { useBrand } from "@/g8/hooks/use-brand";
27
28
 
28
29
  export interface ImportWizardProps {
29
30
  /** Largest file accepted. Default 5 MiB: the /api/g8 proxy rejects bigger bodies (raise maxBodyBytes there first). */
@@ -68,6 +69,7 @@ export function guessMapping(columns: string[]): Record<string, string> {
68
69
  }
69
70
 
70
71
  export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, className }: ImportWizardProps) {
72
+ const brand = useBrand();
71
73
  const [tooBig, setTooBig] = useState<string | null>(null);
72
74
  const [upload, setUpload] = useState<Upload | null>(null);
73
75
  const [mapping, setMapping] = useState<Record<string, string>>({});
@@ -167,7 +169,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
167
169
  </div>
168
170
  ) : null}
169
171
  <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={policy === "override"} onChange={(e) => setPolicy(e.target.checked ? "override" : "autofill")} /> Overwrite existing values (default only fills blanks)</label>
170
- <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enrich} onChange={(e) => setEnrich(e.target.checked)} /> Fill missing fields from graph8 (uses credits)</label>
172
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enrich} onChange={(e) => setEnrich(e.target.checked)} /> Fill missing fields from {brand.linkLabel} (uses credits)</label>
171
173
  </section>
172
174
 
173
175
  {run.error ? <ApiError error={run.error} title="The import failed" /> : null}
@@ -181,7 +183,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
181
183
  open={confirmOpen}
182
184
  tier={enrich ? "billable" : "destructive"}
183
185
  title={`Import ${rows.toLocaleString()} rows`}
184
- description={policy === "override" ? "Matching contacts get their existing values replaced by the file." : "Missing fields are filled from graph8."}
186
+ description={policy === "override" ? "Matching contacts get their existing values replaced by the file." : `Missing fields are filled from ${brand.linkLabel}.`}
185
187
  affected={enrich ? undefined : rows}
186
188
  confirming={run.loading}
187
189
  confirmLabel="Import"
@@ -45,11 +45,11 @@ export function NotesPanel({ entity, entityId, onChanged, className }: NotesPane
45
45
  const [editing, setEditing] = useState<{ id: string; content: string } | null>(null);
46
46
  const [deleting, setDeleting] = useState<NoteItem | null>(null);
47
47
 
48
- const notes = useG8Query((signal) => g8Get<NoteItem[]>(base, undefined, { signal }), [base]);
48
+ const notes = useG8Query((signal) => g8Get<NoteItem[]>(base, undefined, { signal }), [base], { key: ["notes", entity, entityId] });
49
49
  const done = () => { void notes.refetch(); onChanged?.(); };
50
- const add = useG8Mutation((content: string) => g8Post(base, { content }), { onSuccess: () => { setDraft(""); done(); } });
51
- const edit = useG8Mutation((v: { id: string; content: string }) => g8Patch(`/notes/${v.id}`, { content: v.content }), { onSuccess: () => { setEditing(null); done(); } });
52
- const remove = useG8Mutation((id: string) => g8Delete(`/notes/${id}`), { onSuccess: () => { setDeleting(null); done(); } });
50
+ const add = useG8Mutation((content: string) => g8Post(base, { content }), { invalidates: [["notes", entity, entityId]], onSuccess: () => { setDraft(""); done(); } });
51
+ const edit = useG8Mutation((v: { id: string; content: string }) => g8Patch(`/notes/${v.id}`, { content: v.content }), { invalidates: [["notes"]], onSuccess: () => { setEditing(null); done(); } });
52
+ const remove = useG8Mutation((id: string) => g8Delete(`/notes/${id}`), { invalidates: [["notes"]], onSuccess: () => { setDeleting(null); done(); } });
53
53
 
54
54
  const items = (notes.data?.data ?? []) as NoteItem[];
55
55
  return (
@@ -35,6 +35,8 @@ export interface RecordFormProps {
35
35
  /** Create only: a contact with the same identity exists; receives its id. */
36
36
  onDuplicate?: (existingId: number | undefined) => void;
37
37
  onCancel?: () => void;
38
+ /** Create only: prefill base fields, for example `{ company_domain: "acme.com" }`. */
39
+ defaults?: Record<string, string>;
38
40
  /** Show the inline "Add custom field" control. Default true. */
39
41
  allowNewFields?: boolean;
40
42
  className?: string;
@@ -94,13 +96,13 @@ export function RecordForm(props: RecordFormProps) {
94
96
  const [newType, setNewType] = useState("text");
95
97
  const [validation, setValidation] = useState<string | null>(null);
96
98
 
97
- const initial = initialValues(base, record, custom.fields);
99
+ const initial = { ...initialValues(base, record, custom.fields), ...(record ? {} : props.defaults ?? {}) };
98
100
  const value = (key: string) => edits[key] ?? initial[key] ?? "";
99
101
  const set = (key: string, v: string) => setEdits((e) => ({ ...e, [key]: v }));
100
102
 
101
103
  const addField = useG8Mutation(
102
104
  (title: string) => g8Post("/fields", { title, data_type: newType, entity: plural, list_id: listId ?? null }),
103
- { onSuccess: () => { setNewTitle(""); void custom.refetch(); } },
105
+ { invalidates: ["fields"], onSuccess: () => { setNewTitle(""); void custom.refetch(); } },
104
106
  );
105
107
 
106
108
  const save = useG8Mutation(async () => {
@@ -126,7 +128,7 @@ export function RecordForm(props: RecordFormProps) {
126
128
  await g8Patch("/fields/values/batch", { entity: plural, rows: [{ record_id: id, fields: toWrite.map((f) => ({ column_id: f.id, value: value(`cf:${f.name}`) || null })) }] });
127
129
  }
128
130
  return { id: id as number, merged };
129
- }, { onSuccess: (r) => props.onSaved?.(r) });
131
+ }, { invalidates: [plural], onSuccess: (r) => props.onSaved?.(r) });
130
132
 
131
133
  const submit = (event: FormEvent) => {
132
134
  event.preventDefault();
@@ -51,14 +51,14 @@ export function TaskList({ entity, entityId, includeCompleted = false, onChanged
51
51
  const [showDone, setShowDone] = useState(includeCompleted);
52
52
  const [deleting, setDeleting] = useState<TaskItem | null>(null);
53
53
 
54
- const tasks = useG8Query((signal) => g8Get<TaskItem[]>(base, { status: showDone ? undefined : "open" }, { signal }), [base, showDone]);
54
+ const tasks = useG8Query((signal) => g8Get<TaskItem[]>(base, { status: showDone ? undefined : "open" }, { signal }), [base, showDone], { key: ["tasks", "record", entity, entityId, { showDone }] });
55
55
  const done = () => { void tasks.refetch(); onChanged?.(); };
56
56
  const add = useG8Mutation(
57
57
  () => g8Post(base, { title: title.trim(), priority, ...(due ? { due_date: due } : {}) }),
58
- { onSuccess: () => { setTitle(""); setDue(""); done(); } },
58
+ { invalidates: [["tasks"]], onSuccess: () => { setTitle(""); setDue(""); done(); } },
59
59
  );
60
- const toggle = useG8Mutation((t: TaskItem) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { onSuccess: done });
61
- const remove = useG8Mutation((id: string) => g8Delete(`/tasks/${id}`), { onSuccess: () => { setDeleting(null); done(); } });
60
+ const toggle = useG8Mutation((t: TaskItem) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { invalidates: [["tasks"]], onSuccess: done });
61
+ const remove = useG8Mutation((id: string) => g8Delete(`/tasks/${id}`), { invalidates: [["tasks"]], onSuccess: () => { setDeleting(null); done(); } });
62
62
 
63
63
  const items = (tasks.data?.data ?? []) as TaskItem[];
64
64
  return (