@graph8/sdk 0.370.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 (466) hide show
  1. package/README.md +19 -0
  2. package/blocks/_foundation/README.md +14 -0
  3. package/blocks/_foundation/block.json +44 -0
  4. package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
  5. package/blocks/_foundation/components/empty-state.tsx +5 -2
  6. package/blocks/_foundation/components/list-select.tsx +112 -0
  7. package/blocks/_foundation/components/paging-notices.tsx +55 -0
  8. package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
  9. package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
  10. package/blocks/_foundation/components/screen/index.ts +1 -0
  11. package/blocks/_foundation/components/screen/split-view.tsx +1 -1
  12. package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
  13. package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
  14. package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
  15. package/blocks/_foundation/lib/paging.ts +57 -0
  16. package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
  17. package/blocks/agents/README.md +81 -4
  18. package/blocks/agents/block.json +311 -2
  19. package/blocks/agents/components/agent-activity.tsx +112 -0
  20. package/blocks/agents/components/agent-approvals.tsx +135 -0
  21. package/blocks/agents/components/agent-builder.tsx +17 -3
  22. package/blocks/agents/components/agent-conversations.tsx +143 -0
  23. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
  24. package/blocks/agents/components/agent-operators.tsx +164 -0
  25. package/blocks/agents/components/agent-run-detail.tsx +20 -3
  26. package/blocks/agents/components/agent-runs.tsx +3 -3
  27. package/blocks/agents/components/agent-skills-panel.tsx +7 -7
  28. package/blocks/agents/components/agent-test-history.tsx +121 -0
  29. package/blocks/agents/components/agents-screen.tsx +53 -11
  30. package/blocks/agents/components/assistant-sessions.tsx +150 -0
  31. package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
  32. package/blocks/agents/components/from-template-button.tsx +65 -0
  33. package/blocks/agents/components/knowledge-browser.tsx +107 -0
  34. package/blocks/agents/components/knowledge-detail.tsx +156 -0
  35. package/blocks/agents/components/knowledge-form.tsx +69 -0
  36. package/blocks/agents/components/name-dialog.tsx +32 -0
  37. package/blocks/agents/components/receptionist-button.tsx +46 -0
  38. package/blocks/agents/components/save-as-template-button.tsx +32 -0
  39. package/blocks/agents/components/skill-editor.tsx +147 -0
  40. package/blocks/agents/components/skill-library.tsx +111 -0
  41. package/blocks/agents/lib/agent-types.ts +7 -1
  42. package/blocks/agents/lib/facts.tsx +13 -0
  43. package/blocks/agents/lib/knowledge-utils.ts +49 -0
  44. package/blocks/agents/lib/load-more.tsx +141 -0
  45. package/blocks/agents/lib/skill-utils.ts +91 -0
  46. package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
  47. package/blocks/analytics/components/analytics-screen.tsx +16 -3
  48. package/blocks/analytics/components/kpi-tiles.tsx +1 -1
  49. package/blocks/analytics/components/report-builder.tsx +15 -2
  50. package/blocks/appointments/README.md +6 -0
  51. package/blocks/appointments/block.json +27 -0
  52. package/blocks/appointments/components/booking-page.tsx +2 -2
  53. package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
  54. package/blocks/appointments/components/meeting-detail.tsx +2 -2
  55. package/blocks/appointments/components/meeting-list.tsx +29 -13
  56. package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
  57. package/blocks/campaigns/components/campaign-assets.tsx +1 -1
  58. package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
  59. package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
  60. package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
  61. package/blocks/campaigns/components/icp-detail.tsx +11 -2
  62. package/blocks/campaigns/components/icp-editor.tsx +8 -3
  63. package/blocks/campaigns/components/persona-detail.tsx +11 -2
  64. package/blocks/campaigns/components/persona-editor.tsx +6 -1
  65. package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
  66. package/blocks/coach/components/call-scorecard.tsx +7 -7
  67. package/blocks/company-intelligence/README.md +132 -0
  68. package/blocks/company-intelligence/block.json +318 -0
  69. package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
  70. package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
  71. package/blocks/company-intelligence/components/company-profile.tsx +68 -0
  72. package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
  73. package/blocks/company-intelligence/components/report-detail.tsx +189 -0
  74. package/blocks/company-intelligence/components/research-reports.tsx +136 -0
  75. package/blocks/company-intelligence/components/research-run.tsx +161 -0
  76. package/blocks/company-intelligence/components/source-detail.tsx +117 -0
  77. package/blocks/company-intelligence/components/source-view.tsx +274 -0
  78. package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
  79. package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
  80. package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
  81. package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
  82. package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
  83. package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
  84. package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
  85. package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
  86. package/blocks/copilot/components/copilot-memory.tsx +6 -2
  87. package/blocks/copilot/components/copilot-panel.tsx +1 -1
  88. package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
  89. package/blocks/crm/README.md +6 -0
  90. package/blocks/crm/block.json +53 -4
  91. package/blocks/crm/components/companies-screen.tsx +23 -9
  92. package/blocks/crm/components/company-detail.tsx +2 -2
  93. package/blocks/crm/components/contact-detail.tsx +4 -4
  94. package/blocks/crm/components/contact-lookup-link.tsx +44 -0
  95. package/blocks/crm/components/contacts-screen.tsx +31 -13
  96. package/blocks/crm/components/dedupe-review.tsx +6 -1
  97. package/blocks/crm/components/import-wizard.tsx +3 -7
  98. package/blocks/crm/components/list-select.tsx +20 -0
  99. package/blocks/crm/components/record-engagement-tab.tsx +103 -0
  100. package/blocks/crm/components/record-form.tsx +1 -1
  101. package/blocks/crm/components/tasks-screen.tsx +35 -15
  102. package/blocks/crm/hooks/use-all-lists.ts +18 -0
  103. package/blocks/crm/hooks/use-page-query.ts +3 -1
  104. package/blocks/crm/lib/crm-types.ts +14 -0
  105. package/blocks/crm/lib/read-lists.ts +16 -0
  106. package/blocks/deals/README.md +24 -6
  107. package/blocks/deals/block.json +151 -2
  108. package/blocks/deals/components/close-deal-dialog.tsx +169 -0
  109. package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
  110. package/blocks/deals/components/deal-detail.tsx +2 -2
  111. package/blocks/deals/components/deal-filters.tsx +65 -0
  112. package/blocks/deals/components/deal-line-items.tsx +120 -0
  113. package/blocks/deals/components/deal-notes-tab.tsx +97 -0
  114. package/blocks/deals/components/deal-related.tsx +30 -9
  115. package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
  116. package/blocks/deals/components/deals-screen.tsx +57 -22
  117. package/blocks/deals/components/forecast-view.tsx +4 -4
  118. package/blocks/deals/components/pipeline-board.tsx +8 -6
  119. package/blocks/deals/components/pipeline-editor.tsx +132 -0
  120. package/blocks/deals/components/record-deals-tab.tsx +62 -0
  121. package/blocks/deals/components/stage-editor.tsx +1 -1
  122. package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
  123. package/blocks/deals/lib/deal-extras.ts +156 -0
  124. package/blocks/dialer/README.md +14 -5
  125. package/blocks/dialer/block.json +17 -2
  126. package/blocks/dialer/components/call-contact-button.tsx +26 -9
  127. package/blocks/dialer/components/call-log-screen.tsx +4 -1
  128. package/blocks/dialer/components/call-log.tsx +16 -4
  129. package/blocks/dialer/components/call-queue.tsx +3 -3
  130. package/blocks/dialer/components/dialer-screen.tsx +69 -27
  131. package/blocks/dialer/components/disposition-panel.tsx +1 -1
  132. package/blocks/dialer/components/record-calls-tab.tsx +101 -0
  133. package/blocks/dialer/components/session-start-form.tsx +58 -11
  134. package/blocks/dialer/components/softphone.tsx +1 -1
  135. package/blocks/enrichment/README.md +16 -6
  136. package/blocks/enrichment/block.json +19 -7
  137. package/blocks/enrichment/components/bulk-enrich-panel.tsx +76 -27
  138. package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
  139. package/blocks/enrichment/components/enrich-button.tsx +1 -1
  140. package/blocks/enrichment/components/enrichment-screen.tsx +70 -17
  141. package/blocks/enrichment/components/waterfall-config.tsx +27 -7
  142. package/blocks/enrichment/lib/enrichment-types.ts +39 -0
  143. package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
  144. package/blocks/forms/README.md +8 -0
  145. package/blocks/forms/block.json +107 -0
  146. package/blocks/forms/components/form-activity.tsx +71 -0
  147. package/blocks/forms/components/form-backfill.tsx +108 -0
  148. package/blocks/forms/components/form-builder.tsx +1 -1
  149. package/blocks/forms/components/form-embed.tsx +1 -1
  150. package/blocks/forms/components/form-groups.tsx +151 -0
  151. package/blocks/forms/components/form-mapping-editor.tsx +6 -9
  152. package/blocks/forms/components/form-route-history.tsx +72 -0
  153. package/blocks/forms/components/form-saved-views.tsx +81 -0
  154. package/blocks/forms/components/forms-screen.tsx +51 -17
  155. package/blocks/forms/components/more-rows.tsx +30 -0
  156. package/blocks/forms/components/submissions-export.tsx +67 -0
  157. package/blocks/forms/components/submissions-table.tsx +15 -6
  158. package/blocks/forms/lib/forms-extra.ts +67 -0
  159. package/blocks/inbox/README.md +5 -0
  160. package/blocks/inbox/block.json +15 -0
  161. package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
  162. package/blocks/inbox/components/thread-badge.tsx +2 -2
  163. package/blocks/inbox/components/thread-view.tsx +1 -1
  164. package/blocks/integrations/README.md +54 -1
  165. package/blocks/integrations/block.json +189 -15
  166. package/blocks/integrations/components/ad-accounts.tsx +82 -0
  167. package/blocks/integrations/components/connection-health.tsx +59 -0
  168. package/blocks/integrations/components/crm-detail.tsx +20 -1
  169. package/blocks/integrations/components/crm-records.tsx +103 -0
  170. package/blocks/integrations/components/field-mappings.tsx +61 -0
  171. package/blocks/integrations/components/integration-list.tsx +4 -4
  172. package/blocks/integrations/components/integrations-screen.tsx +20 -3
  173. package/blocks/integrations/components/migration-panel.tsx +133 -0
  174. package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
  175. package/blocks/integrations/components/sync-status.tsx +6 -6
  176. package/blocks/integrations/components/sync-triggers.tsx +134 -0
  177. package/blocks/integrations/components/webhook-detail.tsx +26 -7
  178. package/blocks/integrations/components/webhook-manager.tsx +30 -17
  179. package/blocks/integrations/components/workspace-tools.tsx +127 -0
  180. package/blocks/integrations/lib/crm-utils.ts +39 -0
  181. package/blocks/integrations/lib/facts.tsx +13 -0
  182. package/blocks/integrations/lib/integrations-utils.ts +5 -0
  183. package/blocks/integrations/lib/load-more.tsx +141 -0
  184. package/blocks/intent/components/account-intent-score.tsx +5 -5
  185. package/blocks/intent/components/intent-feed.tsx +8 -8
  186. package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
  187. package/blocks/intent/lib/intent-utils.ts +4 -4
  188. package/blocks/lifecycle/components/account-related.tsx +8 -1
  189. package/blocks/lifecycle/components/churn-list.tsx +5 -2
  190. package/blocks/lifecycle/components/customer-table.tsx +1 -1
  191. package/blocks/lists/README.md +7 -1
  192. package/blocks/lists/block.json +31 -3
  193. package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
  194. package/blocks/lists/components/add-to-list-button.tsx +17 -7
  195. package/blocks/lists/components/list-picker.tsx +13 -3
  196. package/blocks/lists/components/list-table.tsx +1 -1
  197. package/blocks/lists/components/lists-screen.tsx +4 -2
  198. package/blocks/lists/components/record-lists-tab.tsx +99 -0
  199. package/blocks/lists/components/suppression-manager.tsx +27 -6
  200. package/blocks/lists/components/suppressions-screen.tsx +21 -12
  201. package/blocks/mailboxes/block.json +4 -0
  202. package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
  203. package/blocks/mailboxes/components/domain-detail.tsx +10 -4
  204. package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
  205. package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
  206. package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
  207. package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
  208. package/blocks/mailboxes/components/warmup-health.tsx +20 -20
  209. package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
  210. package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
  211. package/blocks/newsletter/README.md +12 -0
  212. package/blocks/newsletter/block.json +117 -0
  213. package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
  214. package/blocks/newsletter/components/issue-versions.tsx +93 -0
  215. package/blocks/newsletter/components/more-rows.tsx +30 -0
  216. package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
  217. package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
  218. package/blocks/newsletter/components/newsletter-screen.tsx +36 -13
  219. package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
  220. package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
  221. package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
  222. package/blocks/newsletter/components/subscriber-table.tsx +5 -7
  223. package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
  224. package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
  225. package/blocks/nurture/components/nurture-builder.tsx +10 -2
  226. package/blocks/nurture/components/nurture-enroll.tsx +6 -7
  227. package/blocks/nurture/components/nurture-form.tsx +5 -8
  228. package/blocks/nurture/components/nurture-screen.tsx +16 -5
  229. package/blocks/nurture/components/nurture-stats.tsx +5 -4
  230. package/blocks/objects/README.md +1 -1
  231. package/blocks/objects/block.json +13 -5
  232. package/blocks/objects/components/attribute-field-input.tsx +14 -1
  233. package/blocks/objects/components/object-record-detail.tsx +8 -1
  234. package/blocks/objects/components/object-schema-editor.tsx +1 -1
  235. package/blocks/objects/components/record-roles-panel.tsx +15 -5
  236. package/blocks/pages/README.md +14 -0
  237. package/blocks/pages/block.json +113 -1
  238. package/blocks/pages/components/more-rows.tsx +30 -0
  239. package/blocks/pages/components/page-ai-tools.tsx +196 -0
  240. package/blocks/pages/components/page-analytics.tsx +176 -0
  241. package/blocks/pages/components/page-clone.tsx +99 -0
  242. package/blocks/pages/components/page-domains.tsx +167 -0
  243. package/blocks/pages/components/page-editor.tsx +1 -1
  244. package/blocks/pages/components/page-generation-status.tsx +48 -0
  245. package/blocks/pages/components/page-tracking.tsx +137 -0
  246. package/blocks/pages/components/pages-screen.tsx +57 -16
  247. package/blocks/pages/components/seo-report.tsx +6 -1
  248. package/blocks/pages/lib/studio.ts +7 -0
  249. package/blocks/phone-numbers/README.md +74 -0
  250. package/blocks/phone-numbers/block.json +262 -0
  251. package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
  252. package/blocks/phone-numbers/components/assign-number.tsx +104 -0
  253. package/blocks/phone-numbers/components/number-search.tsx +239 -0
  254. package/blocks/phone-numbers/components/number-settings.tsx +134 -0
  255. package/blocks/phone-numbers/components/number-usage.tsx +97 -0
  256. package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
  257. package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
  258. package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
  259. package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
  260. package/blocks/phone-numbers/components/release-number.tsx +126 -0
  261. package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
  262. package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
  263. package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
  264. package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
  265. package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
  266. package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
  267. package/blocks/pickers/README.md +23 -0
  268. package/blocks/pickers/block.json +103 -0
  269. package/blocks/pickers/components/company-picker.tsx +90 -0
  270. package/blocks/pickers/components/contact-picker.tsx +131 -0
  271. package/blocks/pickers/components/member-picker.tsx +99 -0
  272. package/blocks/pickers/components/search-picker.tsx +98 -0
  273. package/blocks/pickers/hooks/use-current-member.ts +104 -0
  274. package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
  275. package/blocks/pickers/lib/use-debounced.ts +13 -0
  276. package/blocks/quotes/README.md +13 -0
  277. package/blocks/quotes/block.json +48 -1
  278. package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
  279. package/blocks/quotes/components/quote-builder.tsx +1 -1
  280. package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
  281. package/blocks/quotes/components/quote-list.tsx +5 -3
  282. package/blocks/quotes/components/quote-related.tsx +3 -2
  283. package/blocks/quotes/components/quote-viewer.tsx +7 -2
  284. package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
  285. package/blocks/radar/components/competitor-battle-card.tsx +1 -1
  286. package/blocks/radar/components/radar-overview.tsx +4 -4
  287. package/blocks/radar/components/radar-screen.tsx +2 -2
  288. package/blocks/sequencer/README.md +6 -0
  289. package/blocks/sequencer/block.json +28 -1
  290. package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
  291. package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
  292. package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
  293. package/blocks/sequencer/components/sequence-builder.tsx +19 -20
  294. package/blocks/sequencer/components/sequence-detail.tsx +4 -2
  295. package/blocks/sequencer/components/sequence-enrollments.tsx +4 -2
  296. package/blocks/sequencer/components/sequence-list.tsx +1 -1
  297. package/blocks/sequencer/components/sequence-stats.tsx +8 -8
  298. package/blocks/sequencer/components/sequences-screen.tsx +3 -0
  299. package/blocks/sequencer/components/step-editor.tsx +11 -11
  300. package/blocks/sequencer/lib/sequencer-utils.ts +5 -5
  301. package/blocks/social-ads/README.md +8 -0
  302. package/blocks/social-ads/block.json +137 -4
  303. package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
  304. package/blocks/social-ads/components/audience-sync.tsx +6 -2
  305. package/blocks/social-ads/components/campaign-ads.tsx +166 -0
  306. package/blocks/social-ads/components/campaign-detail.tsx +6 -1
  307. package/blocks/social-ads/components/campaign-editor.tsx +134 -0
  308. package/blocks/social-ads/components/creative-concepts.tsx +74 -0
  309. package/blocks/social-ads/components/creative-detail.tsx +6 -1
  310. package/blocks/social-ads/components/creative-gallery.tsx +20 -2
  311. package/blocks/social-ads/components/creative-revisions.tsx +57 -0
  312. package/blocks/social-ads/components/creative-tools.tsx +176 -0
  313. package/blocks/social-ads/components/post-composer.tsx +4 -2
  314. package/blocks/social-ads/components/post-detail.tsx +3 -0
  315. package/blocks/social-ads/components/social-ideas.tsx +64 -0
  316. package/blocks/social-ads/components/social-performance.tsx +80 -0
  317. package/blocks/social-ads/components/social-screen.tsx +33 -6
  318. package/blocks/social-ads/lib/ads-extra.ts +67 -0
  319. package/blocks/surveys/README.md +91 -0
  320. package/blocks/surveys/block.json +299 -0
  321. package/blocks/surveys/components/account-nps.tsx +69 -0
  322. package/blocks/surveys/components/contact-survey-history.tsx +52 -0
  323. package/blocks/surveys/components/org-summary.tsx +82 -0
  324. package/blocks/surveys/components/sending-domains.tsx +113 -0
  325. package/blocks/surveys/components/survey-form.tsx +126 -0
  326. package/blocks/surveys/components/survey-responses.tsx +145 -0
  327. package/blocks/surveys/components/survey-results.tsx +72 -0
  328. package/blocks/surveys/components/survey-send-history.tsx +58 -0
  329. package/blocks/surveys/components/survey-send.tsx +162 -0
  330. package/blocks/surveys/components/survey-triggers.tsx +166 -0
  331. package/blocks/surveys/components/surveys-screen.tsx +234 -0
  332. package/blocks/surveys/lib/surveys-types.ts +228 -0
  333. package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
  334. package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
  335. package/blocks/surveys/recipes/survey-score-report.ts +27 -0
  336. package/blocks/visitors/components/snippet-install.tsx +18 -9
  337. package/blocks/visitors/components/traffic-chart.tsx +4 -4
  338. package/blocks/visitors/components/visitor-company-card.tsx +24 -14
  339. package/blocks/visitors/components/visitor-feed.tsx +5 -5
  340. package/blocks/webchat/components/chat-widget-config.tsx +1 -1
  341. package/blocks/webchat/components/conversation-list.tsx +1 -1
  342. package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
  343. package/blocks/workbench/components/run-progress.tsx +1 -1
  344. package/blocks/workbench/components/workbench-grid.tsx +1 -1
  345. package/blocks/workbench/components/workbench-screen.tsx +15 -5
  346. package/blocks/workbench/components/workbench-table-list.tsx +14 -2
  347. package/blocks/workflows/components/workflow-editor.tsx +1 -1
  348. package/dist/cli.js +536 -392
  349. package/dist/index.browser.js +1 -1
  350. package/dist/index.browser.js.map +1 -1
  351. package/dist/index.browser.mjs +1 -1
  352. package/dist/index.browser.mjs.map +1 -1
  353. package/dist/index.d.mts +2 -2
  354. package/dist/index.d.ts +2 -2
  355. package/dist/index.js +1 -1
  356. package/dist/index.js.map +1 -1
  357. package/dist/index.mjs +1 -1
  358. package/dist/index.mjs.map +1 -1
  359. package/dist/react.browser.js +1 -1
  360. package/dist/react.browser.js.map +1 -1
  361. package/dist/react.browser.mjs +1 -1
  362. package/dist/react.browser.mjs.map +1 -1
  363. package/dist/react.d.mts +2 -2
  364. package/dist/react.d.ts +2 -2
  365. package/dist/react.js +1 -1
  366. package/dist/react.js.map +1 -1
  367. package/dist/react.mjs +1 -1
  368. package/dist/react.mjs.map +1 -1
  369. package/dist/types/blocks/brand.d.ts +11 -2
  370. package/dist/types/blocks/create.d.ts +7 -3
  371. package/dist/types/blocks/doctor.d.ts +1 -0
  372. package/dist/types/blocks/flows.d.ts +7 -1
  373. package/dist/types/blocks/orgBrand.d.ts +34 -0
  374. package/dist/types/cli.d.ts +3 -0
  375. package/dist/types/version.d.ts +2 -2
  376. package/docs/api/desk.d.ts +2 -2
  377. package/docs/api/desk.md +3 -3
  378. package/package.json +1 -1
  379. package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
  380. package/stacks/6sense/pages/contacts.tsx.tmpl +19 -0
  381. package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
  382. package/stacks/6sense.json +17 -4
  383. package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
  384. package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
  385. package/stacks/apollo/pages/contacts.tsx.tmpl +29 -95
  386. package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
  387. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
  388. package/stacks/apollo/pages/lists.tsx.tmpl +12 -0
  389. package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
  390. package/stacks/apollo/pages/sequences.tsx.tmpl +12 -0
  391. package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
  392. package/stacks/apollo.json +36 -8
  393. package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
  394. package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
  395. package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
  396. package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
  397. package/stacks/calendly.json +8 -4
  398. package/stacks/clay/pages/companies.tsx.tmpl +17 -1
  399. package/stacks/clay/pages/contacts.tsx.tmpl +12 -0
  400. package/stacks/clay/pages/lists.tsx.tmpl +12 -0
  401. package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
  402. package/stacks/clay.json +16 -3
  403. package/stacks/gong/pages/calls.tsx.tmpl +23 -0
  404. package/stacks/gong/pages/companies.tsx.tmpl +22 -0
  405. package/stacks/gong/pages/contacts.tsx.tmpl +32 -0
  406. package/stacks/gong/pages/lists.tsx.tmpl +12 -0
  407. package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
  408. package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
  409. package/stacks/gong.json +23 -6
  410. package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
  411. package/stacks/hootsuite/pages/contacts.tsx.tmpl +28 -0
  412. package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
  413. package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
  414. package/stacks/hootsuite.json +9 -4
  415. package/stacks/hubspot/pages/companies.tsx.tmpl +14 -35
  416. package/stacks/hubspot/pages/contacts.tsx.tmpl +29 -95
  417. package/stacks/hubspot/pages/deals.tsx.tmpl +10 -1
  418. package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
  419. package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
  420. package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
  421. package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
  422. package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
  423. package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
  424. package/stacks/hubspot.json +21 -5
  425. package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
  426. package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
  427. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
  428. package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
  429. package/stacks/intercom.json +8 -4
  430. package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
  431. package/stacks/mailchimp/pages/contacts.tsx.tmpl +28 -0
  432. package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
  433. package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
  434. package/stacks/mailchimp.json +20 -5
  435. package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
  436. package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
  437. package/stacks/outreach/pages/contacts.tsx.tmpl +27 -66
  438. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
  439. package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
  440. package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
  441. package/stacks/outreach/pages/sequences.tsx.tmpl +12 -0
  442. package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
  443. package/stacks/outreach.json +25 -7
  444. package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
  445. package/stacks/pipedrive/pages/contacts.tsx.tmpl +15 -35
  446. package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
  447. package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
  448. package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
  449. package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
  450. package/stacks/pipedrive.json +4 -2
  451. package/stacks/salesforce/pages/companies.tsx.tmpl +14 -35
  452. package/stacks/salesforce/pages/contacts.tsx.tmpl +20 -35
  453. package/stacks/salesforce/pages/deals.tsx.tmpl +10 -1
  454. package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
  455. package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
  456. package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
  457. package/stacks/salesforce.json +7 -3
  458. package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
  459. package/stacks/zoominfo/pages/contacts.tsx.tmpl +12 -0
  460. package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
  461. package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
  462. package/stacks/zoominfo.json +16 -3
  463. package/styles/styles.css +1 -1
  464. package/templates/next-app/app/globals.css.tmpl +349 -64
  465. package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
  466. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ContactLookupLink: "Open contact" for something that only knows a person's email (an inbox thread, a meeting
5
+ * attendee, a visitor). It looks the email up in your CRM and, when exactly one contact has it, shows a button that
6
+ * calls `onOpen(contactId)` (route to your contacts screen: `router.push("/contacts?id=" + id)`). When nobody has that
7
+ * email it renders nothing, so a thread from a stranger gets no dead button.
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. listContacts GET /contacts?email=<email>&limit=2 (read)
11
+ * Nothing here is billable, external or destructive.
12
+ */
13
+
14
+ import { Button } from "@graph8/sdk/ui";
15
+ import { UserRound } from "lucide-react";
16
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
17
+ import { g8Get } from "@/g8/lib/g8-client";
18
+
19
+ export interface ContactLookupLinkProps {
20
+ /** The person's email. Nothing is looked up (and nothing rendered) without one. */
21
+ email?: string | null;
22
+ /** Called with the CRM contact id when the button is clicked. */
23
+ onOpen: (contactId: number) => void;
24
+ label?: string;
25
+ variant?: "default" | "outline" | "secondary" | "ghost";
26
+ size?: "default" | "sm";
27
+ }
28
+
29
+ export function ContactLookupLink({ email, onOpen, label = "Open contact", variant = "outline", size = "sm" }: ContactLookupLinkProps) {
30
+ const address = email?.trim().toLowerCase() ?? "";
31
+ const q = useG8Query(
32
+ async (signal) => ((await g8Get<Array<{ id: number; work_email?: string | null }>>("/contacts", { email: address, limit: 2, page: 1 }, { signal })).data ?? []),
33
+ [address],
34
+ { enabled: address.includes("@"), key: ["contacts", "by-email", address] },
35
+ );
36
+ const match = (q.data ?? []).find((c) => !c.work_email || c.work_email.toLowerCase() === address);
37
+ if (!address || !match) return null;
38
+ return (
39
+ <Button size={size} variant={variant} onClick={() => onOpen(match.id)}>
40
+ <UserRound className="mr-1 size-4" aria-hidden />
41
+ {label}
42
+ </Button>
43
+ );
44
+ }
@@ -40,10 +40,12 @@ import { RecordLink } from "@/g8/components/screen/record-link";
40
40
  import { plural } from "@/g8/lib/plural";
41
41
  import { useG8Query } from "@/g8/hooks/use-g8-query";
42
42
  import { useUrlState } from "@/g8/hooks/use-url-state";
43
+ import { useUrlFilters } from "@/g8/hooks/use-url-filters";
44
+ import { ListSelect } from "./list-select";
45
+ import { MoreFilters, TextFilter } from "@/g8/components/screen/filter-controls";
43
46
  import { g8Delete, g8Get } from "@/g8/lib/g8-client";
44
- import { useDebouncedValue } from "../hooks/use-debounced";
45
47
  import { usePageQuery } from "../hooks/use-page-query";
46
- import { contactName, errorStatus, isMasked, location } from "../lib/crm-types";
48
+ import { contactName, contactSubtitle, errorStatus, isMasked, location } from "../lib/crm-types";
47
49
  import type { ContactRecord, ScreenActionContext } from "../lib/crm-types";
48
50
  import { Pager, SortHead, formatDay, sortRows, useBulkDelete, useRowSelection, useSort } from "../lib/screen-parts";
49
51
  import { ActivityTimeline } from "./activity-timeline";
@@ -72,8 +74,10 @@ export interface ContactsScreenProps {
72
74
  className?: string;
73
75
  }
74
76
 
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
+ /** Every filter the contacts API takes (name, email, job title, seniority, company name and id, country, state, city, department, industry), plus `list`. */
78
+ const FILTER_KEYS = ["name", "job_title", "seniority_level", "country", "email", "company_name", "company_id", "state", "city", "job_department", "industry", "list"] as const;
79
+ type FilterKey = (typeof FILTER_KEYS)[number];
80
+ const MORE_KEYS: readonly FilterKey[] = ["email", "company_name", "company_id", "state", "city", "job_department", "industry", "list"];
77
81
  const SENIORITY = ["C-Suite", "VP", "Director", "Manager"];
78
82
 
79
83
  function sortValue(row: ContactRecord, key: string): string | null | undefined {
@@ -101,23 +105,27 @@ export function ContactsScreen(props: ContactsScreenProps) {
101
105
  const parsedId = idParam ? Number(idParam) : null;
102
106
  /** A malformed deep link (`?id=abc`) opens nothing instead of fetching /contacts/NaN. */
103
107
  const openId = parsedId != null && Number.isFinite(parsedId) ? parsedId : null;
104
- const [draft, setDraft] = useState<Filters>(NO_FILTERS);
105
- const filters = useDebouncedValue(draft);
108
+ // The filters live in the URL (`c_email=...`), so a filtered view survives reload, Back and sharing.
109
+ const urlFilters = useUrlFilters(FILTER_KEYS, { prefix: "c_" });
110
+ const { draft, filters } = urlFilters;
111
+ const apiFilters = Object.fromEntries(Object.entries(filters).filter(([k, v]) => v && k !== "list"));
112
+ const effectiveListId = listId ?? (filters.list ? Number(filters.list) : undefined);
106
113
  const [sheet, setSheet] = useState<"new" | "import" | "dupes" | null>(null);
107
114
  const [confirmDelete, setConfirmDelete] = useState(false);
108
115
  const [note, setNote] = useState<string | null>(null);
109
116
  const { sort, toggle: toggleSort } = useSort();
110
117
 
111
118
  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],
119
+ (page, signal) => g8Get<ContactRecord[]>("/contacts", { page, limit: pageSize, list_id: effectiveListId, ...apiFilters }, { signal }),
120
+ [effectiveListId, filters],
114
121
  pageSize,
115
122
  ["contacts", "list", "screen"],
123
+ urlFilters.ready,
116
124
  );
117
125
  const dupes = useG8Query((signal) => g8Get<{ pending?: number; count?: number }>("/duplicates/counts", { entity_type: "contact" }, { signal }), [], { key: ["duplicates", "counts", "contact"] });
118
126
  const selection = useRowSelection<ContactRecord>(`${listId}|${JSON.stringify(filters)}`);
119
127
  const rows = useMemo(() => sortRows(list.rows, sort, sortValue), [list.rows, sort]);
120
- const hasFilters = Object.values(draft).some(Boolean);
128
+ const hasFilters = urlFilters.active;
121
129
  const pending = dupes.data?.data.pending ?? dupes.data?.data.count;
122
130
  const lockedOnPage = list.rows.filter((r) => isMasked(r.work_email)).length;
123
131
 
@@ -131,7 +139,7 @@ export function ContactsScreen(props: ContactsScreenProps) {
131
139
  });
132
140
  const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
133
141
  const reload = () => { void list.refetch(); void dupes.refetch(); };
134
- const set = (key: keyof Filters, v: string) => setDraft((f) => ({ ...f, [key]: v }));
142
+ const set = (key: FilterKey, v: string) => urlFilters.set(key, v);
135
143
 
136
144
  useEffect(() => { setNote(null); }, [filters, listId]);
137
145
 
@@ -173,7 +181,17 @@ export function ContactsScreen(props: ContactsScreenProps) {
173
181
  {SENIORITY.map((s) => <option key={s} value={s}>{s}</option>)}
174
182
  </select>
175
183
  <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}
184
+ <MoreFilters activeCount={MORE_KEYS.filter((k) => draft[k]).length}>
185
+ <TextFilter label="email" type="email" value={draft.email} onChange={(v) => set("email", v)} className="w-44" />
186
+ <TextFilter label="company name" placeholder="Company" value={draft.company_name} onChange={(v) => set("company_name", v)} />
187
+ <TextFilter label="company ID" placeholder="Company ID" type="number" value={draft.company_id} onChange={(v) => set("company_id", v)} className="w-28" />
188
+ <TextFilter label="industry" value={draft.industry} onChange={(v) => set("industry", v)} />
189
+ <TextFilter label="department" placeholder="Department" value={draft.job_department} onChange={(v) => set("job_department", v)} />
190
+ <TextFilter label="state" value={draft.state} onChange={(v) => set("state", v)} className="w-28" />
191
+ <TextFilter label="city" value={draft.city} onChange={(v) => set("city", v)} className="w-32" />
192
+ {listId == null ? <ListSelect label="Filter by list" emptyOption="Any list" value={draft.list} valueLabel={`List ${draft.list}`} pageSize={100} onChange={(l) => set("list", l ? String(l.id) : "")} /> : null}
193
+ </MoreFilters>
194
+ {hasFilters ? <Button variant="ghost" size="sm" onClick={urlFilters.clear}>Clear filters</Button> : null}
177
195
  </>
178
196
  }
179
197
  />
@@ -229,7 +247,7 @@ export function ContactsScreen(props: ContactsScreenProps) {
229
247
  <EmptyState
230
248
  title={hasFilters ? "No contacts match these filters" : "No contacts yet"}
231
249
  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>}
250
+ action={hasFilters ? <Button variant="outline" onClick={urlFilters.clear}>Clear filters</Button> : <Button onClick={() => setSheet("new")}>New contact</Button>}
233
251
  />
234
252
  ) : null}
235
253
  <Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="contacts" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
@@ -326,7 +344,7 @@ function ContactPanel({ full, setFull, contactId, seed, onClose, onChanged, onDe
326
344
  <>
327
345
  <DetailPanel
328
346
  title={name}
329
- subtitle={[contact.job_title, contact.company?.name ?? contact.company_name].filter(Boolean).join(" at ") || "No title or company"}
347
+ subtitle={contactSubtitle(contact)}
330
348
  avatar={<EntityAvatar name={name} kind="person" size="lg" />}
331
349
  badges={isMasked(contact.work_email) ? <StatusPill tone="warning" dot>Locked email</StatusPill> : undefined}
332
350
  onClose={onClose}
@@ -21,6 +21,7 @@
21
21
  import { useState } from "react";
22
22
  import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from "@graph8/sdk/ui";
23
23
  import { ApiError } from "@/g8/components/api-error";
24
+ import { FirstNotice } from "@/g8/components/paging-notices";
24
25
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
25
26
  import { EmptyState } from "@/g8/components/empty-state";
26
27
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
@@ -53,6 +54,9 @@ function side(pair: Pair, s: "a" | "b", entity: "contact" | "company") {
53
54
  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))) };
54
55
  }
55
56
 
57
+ /** The recent-merges API has no paging: a full read is announced under the list. */
58
+ const RECENT_LIMIT = 25;
59
+
56
60
  export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps) {
57
61
  const brand = useBrand();
58
62
  const plural = entity === "contact" ? "contacts" : "companies";
@@ -67,7 +71,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
67
71
  const res = await g8Get<{ items: Pair[]; next_cursor?: string | null }>("/duplicates", { entity_type: entity, status: "pending", limit: 25, cursor }, { signal });
68
72
  return { data: res.data.items ?? [], pagination: { next_cursor: res.data.next_cursor ?? null } };
69
73
  }, [entity]);
70
- const recent = useG8Query((signal) => g8Get<MergeRow[]>("/duplicates/recent-merges", { entity_type: entity, limit: 25 }, { signal }), [entity]);
74
+ const recent = useG8Query((signal) => g8Get<MergeRow[]>("/duplicates/recent-merges", { entity_type: entity, limit: RECENT_LIMIT }, { signal }), [entity]);
71
75
  const [busy, setBusy] = useState<string | null>(null);
72
76
  const dropPick = (id: string | undefined) => id && setPicked((p) => { const n = new Set(p); n.delete(id); return n; });
73
77
  const refreshAll = () => { void pairs.reload(); void counts.refetch(); void recent.refetch(); onChanged?.(); };
@@ -145,6 +149,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
145
149
  {m.can_undo ? <Button size="sm" variant="outline" loading={undo.loading && busy === m.id} onClick={() => void undo.mutate(m.id)}>Undo</Button> : null}
146
150
  </div>
147
151
  ))}
152
+ <FirstNotice shown={(recent.data?.data ?? []).length} limit={RECENT_LIMIT} noun="merges" />
148
153
  </TabsContent>
149
154
  </Tabs>
150
155
 
@@ -20,6 +20,7 @@
20
20
  import { useEffect, useState } from "react";
21
21
  import { Button, Input, Label, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
22
22
  import { ApiError } from "@/g8/components/api-error";
23
+ import { ListSelect } from "./list-select";
23
24
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
24
25
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
25
26
  import { useG8Query } from "@/g8/hooks/use-g8-query";
@@ -38,7 +39,6 @@ export interface ImportWizardProps {
38
39
  export interface ImportResult { imported?: number | null; updated?: number | null; skipped?: number | null; failed?: number | null; list_id?: number | null }
39
40
  interface Upload { datafile_id: string; row_count?: number | null; columns: string[] }
40
41
  interface Preview { rows: Array<Record<string, unknown>>; total: number }
41
- interface ListOption { id: number; title: string; type?: string | null }
42
42
 
43
43
  export const IMPORT_TARGETS: Array<{ value: string; label: string }> = [
44
44
  { value: "CONTACT_FIRST_NAME", label: "First name" }, { value: "CONTACT_LAST_NAME", label: "Last name" },
@@ -80,7 +80,6 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
80
80
  const [confirmOpen, setConfirmOpen] = useState(false);
81
81
  const [result, setResult] = useState<ImportResult | null>(null);
82
82
 
83
- const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }), [], { enabled: upload != null && listId == null });
84
83
  const send = useG8Mutation((file: File) => { const body = new FormData(); body.append("file", file); return g8Post<Upload>("/imports", body); }, {
85
84
  onSuccess: (r) => { setUpload(r.data); setMapping(guessMapping(r.data.columns ?? [])); },
86
85
  });
@@ -107,7 +106,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
107
106
  if (result) {
108
107
  return (
109
108
  <div className={`flex flex-col gap-3 ${className ?? ""}`} role="status">
110
- <h3 className="text-base font-semibold">Import finished</h3>
109
+ <h3 className="text-base font-medium">Import finished</h3>
111
110
  <p className="text-sm">{result.imported ?? 0} new, {result.updated ?? 0} updated, {result.skipped ?? 0} skipped, {result.failed ?? 0} failed.</p>
112
111
  <Button variant="outline" onClick={() => { setResult(null); setUpload(null); setMapping({}); setDestination(listId); setNewListTitle(""); setPolicy("autofill"); setEnrich(false); run.reset(); preview.reset(); }}>Import another file</Button>
113
112
  </div>
@@ -161,10 +160,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
161
160
  <h3 className="text-sm font-medium">3. Destination and rules</h3>
162
161
  {listId == null ? (
163
162
  <div className="flex flex-wrap items-center gap-2">
164
- <select aria-label="Destination list" className="h-9 rounded-md border bg-background px-2 text-sm" value={destination ?? ""} onChange={(e) => setDestination(e.target.value ? Number(e.target.value) : undefined)}>
165
- <option value="">New list...</option>
166
- {(lists.data?.data ?? []).filter((l) => !l.type || l.type === "contacts").map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
167
- </select>
163
+ <ListSelect label="Destination list" emptyOption="New list..." type="contacts" pageSize={200} value={destination} valueLabel={newListTitle.trim() || undefined} enabled={upload != null && listId == null} selectClassName="h-9 rounded-md border bg-background px-2 text-sm" onChange={(l) => setDestination(l ? l.id : undefined)} />
168
164
  {destination == null ? <Input aria-label="New list name" placeholder="New list name" className="w-56" value={newListTitle} onChange={(e) => setNewListTitle(e.target.value)} /> : null}
169
165
  </div>
170
166
  ) : null}
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ListSelect: the audience-list dropdown with the list read wired in. A native select that is searchable (server
5
+ * search once there is more than one page) and paged ("Load more lists"), so the 201st list is reachable. It is the
6
+ * foundation's ListSelect plus `GET /lists` (the foundation holds no API scope); every block that picks a list
7
+ * (forms, lists, newsletter, nurture, sequencer, dialer) imports this one.
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. GET /lists?search&page&limit (lists.listLists) read - through lib/read-lists.ts
11
+ */
12
+ import { ListSelect as BaseListSelect } from "@/g8/components/list-select";
13
+ import type { ListSelectProps } from "@/g8/components/list-select";
14
+ import { readLists } from "../lib/read-lists";
15
+
16
+ export type { ListSelectOption } from "@/g8/components/list-select";
17
+
18
+ export function ListSelect(props: Omit<ListSelectProps, "read">) {
19
+ return <BaseListSelect {...props} read={readLists} />;
20
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RecordEngagementTab: how much a contact or company has engaged: emails, calls, meetings, LinkedIn, website visits and
5
+ * downloads, the last touch, and the pages visited in the last 90 days. Use it as an `extraDetailTabs` entry of
6
+ * ContactsScreen / CompaniesScreen:
7
+ * { id: "engagement", label: "Engagement", render: (c) => <RecordEngagementTab kind="contacts" id={c.id} /> }
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. getContactEngagementSummary GET /contacts/{id}/engagement-summary (read) when kind="contacts"
11
+ * getCompanyEngagementSummary GET /companies/{id}/engagement-summary (read) when kind="companies"
12
+ * 2. getContactSitesVisited GET /contacts/{id}/sites-visited?days=90 (read) when kind="contacts"
13
+ * getCompanySitesVisited GET /companies/{id}/sites-visited?days=90 (read) when kind="companies"
14
+ * Nothing here is billable, external or destructive. The visits list is shown ten at a time with "Show more", so the
15
+ * count in the heading always matches what can be reached.
16
+ */
17
+
18
+ import { useState } from "react";
19
+ import { Button, Skeleton } from "@graph8/sdk/ui";
20
+ import { Activity } from "lucide-react";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { EmptyState } from "@/g8/components/empty-state";
23
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
24
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
25
+ import { g8Get } from "@/g8/lib/g8-client";
26
+
27
+ /** The counts both summaries share (the contact one adds LinkedIn and the last touch). */
28
+ interface Summary { emails?: number | null; calls?: number | null; meetings?: number | null; linkedin?: number | null; website_visits?: number | null; downloads?: number | null; contact_count?: number | null; last_contact?: string | null; last_contact_checked?: boolean | null }
29
+ interface Visit { url?: string | null; domain?: string | null; title?: string | null; event_date?: string | null; page_type?: string | null }
30
+ interface Visits { visits?: Visit[] | null; total_visits?: number | null }
31
+
32
+ const STEP = 10;
33
+ const DAYS = 90;
34
+
35
+ function dataOf<T>(env: unknown): T {
36
+ const d = (env as { data?: unknown } | null)?.data;
37
+ return (d && typeof d === "object" ? d : {}) as T;
38
+ }
39
+
40
+ function when(iso: string | null | undefined): string {
41
+ const d = iso ? new Date(iso) : null;
42
+ return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "";
43
+ }
44
+
45
+ const count = (n: number | null | undefined): string => (typeof n === "number" ? n.toLocaleString() : "-");
46
+
47
+ export interface RecordEngagementTabProps {
48
+ kind: "contacts" | "companies";
49
+ id: number;
50
+ }
51
+
52
+ export function RecordEngagementTab({ kind, id }: RecordEngagementTabProps) {
53
+ const [shown, setShown] = useState(STEP);
54
+ const summary = useG8Query(async (signal) => dataOf<Summary>(await g8Get(`/${kind}/${id}/engagement-summary`, undefined, { signal })), [kind, id]);
55
+ const visits = useG8Query(async (signal) => dataOf<Visits>(await g8Get(`/${kind}/${id}/sites-visited`, { days: DAYS }, { signal })), [kind, id]);
56
+
57
+ if (summary.error) return <ApiError error={summary.error} title="Could not load engagement" onRetry={() => void summary.refetch()} />;
58
+ if (!summary.data) return <Skeleton className="h-32 w-full" aria-label="Loading engagement" />;
59
+ const s = summary.data;
60
+ const items = [
61
+ { label: "Emails", value: count(s.emails) },
62
+ { label: "Calls", value: count(s.calls) },
63
+ { label: "Meetings", value: count(s.meetings) },
64
+ ...(kind === "contacts" ? [{ label: "LinkedIn", value: count(s.linkedin) }] : [{ label: "People", value: count(s.contact_count) }]),
65
+ { label: "Website visits", value: count(s.website_visits) },
66
+ { label: "Downloads", value: count(s.downloads) },
67
+ ...(kind === "contacts" ? [{ label: "Last touch", value: when(s.last_contact) || (s.last_contact_checked === false ? "Not checked" : "No touch yet") }] : []),
68
+ ];
69
+ const rows = visits.data?.visits ?? [];
70
+ const total = rows.length;
71
+ const reported = visits.data?.total_visits ?? total;
72
+ return (
73
+ <div className="space-y-4">
74
+ <KeyValueList columns={2} items={items} />
75
+ <section aria-label="Pages visited" className="space-y-2">
76
+ <h3 className="text-sm font-medium">Pages visited, last {DAYS} days</h3>
77
+ {visits.error ? <ApiError error={visits.error} title="Could not load pages visited" onRetry={() => void visits.refetch()} /> : null}
78
+ {!visits.error && !visits.data ? <Skeleton className="h-16 w-full" aria-label="Loading pages visited" /> : null}
79
+ {visits.data && total === 0 ? <EmptyState compact icon={<Activity className="size-8" />} title="No pages visited" description={`No website visits in the last ${DAYS} days.`} /> : null}
80
+ {total > 0 ? (
81
+ <>
82
+ <ul className="divide-y rounded-md border">
83
+ {rows.slice(0, shown).map((v, i) => (
84
+ <li key={`${v.url}-${i}`} className="flex items-center justify-between gap-3 p-3 text-sm">
85
+ <span className="min-w-0">
86
+ <span className="block truncate font-medium">{v.title || v.url || "Page"}</span>
87
+ <span className="block truncate text-xs text-muted-foreground">{[v.domain, v.page_type].filter(Boolean).join(" · ")}</span>
88
+ </span>
89
+ <span className="shrink-0 text-xs text-muted-foreground">{when(v.event_date)}</span>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
94
+ <span role="status">Showing {Math.min(shown, total).toLocaleString()} of {total.toLocaleString()} visits</span>
95
+ {shown < total ? <Button size="sm" variant="outline" onClick={() => setShown((n) => n + STEP)}>Show more</Button> : null}
96
+ </div>
97
+ {reported > total ? <p role="note" className="text-xs text-muted-foreground">The API returned {total.toLocaleString()} of {reported.toLocaleString()} visits in this window. Narrow it in the visitors screen to see the rest.</p> : null}
98
+ </>
99
+ ) : null}
100
+ </section>
101
+ </div>
102
+ );
103
+ }
@@ -178,7 +178,7 @@ export function RecordForm(props: RecordFormProps) {
178
178
 
179
179
  {validation ? <p role="alert" className="text-sm text-destructive">{validation}</p> : null}
180
180
  {duplicate ? (
181
- <div role="alert" className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm">
181
+ <div role="alert" className="rounded-md border border-warning-foreground/30 bg-warning p-3 text-sm">
182
182
  A contact with this email or LinkedIn URL already exists{existingId ? ` (id ${existingId})` : ""}.
183
183
  {props.onDuplicate ? <Button type="button" variant="link" size="sm" onClick={() => props.onDuplicate?.(existingId)}>Open the existing contact</Button> : null}
184
184
  </div>
@@ -37,8 +37,9 @@ import { RecordLink } from "@/g8/components/screen/record-link";
37
37
  import { plural } from "@/g8/lib/plural";
38
38
  import { useG8Query } from "@/g8/hooks/use-g8-query";
39
39
  import { useUrlState } from "@/g8/hooks/use-url-state";
40
+ import { useUrlFilters } from "@/g8/hooks/use-url-filters";
41
+ import { MoreFilters, TextFilter } from "@/g8/components/screen/filter-controls";
40
42
  import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
41
- import { useDebouncedValue } from "../hooks/use-debounced";
42
43
  import { usePageQuery } from "../hooks/use-page-query";
43
44
  import { errorStatus } from "../lib/crm-types";
44
45
  import { Pager, formatDay, useBulkDelete, useRowSelection } from "../lib/screen-parts";
@@ -54,6 +55,8 @@ export interface WorkspaceTask extends TaskItem {
54
55
  }
55
56
 
56
57
  export interface TasksScreenProps {
58
+ /** The signed-in person's user id. With it the toolbar gets a "My tasks" toggle (assignee filter); without it the toggle is hidden. */
59
+ currentUserId?: string;
57
60
  pageSize?: number;
58
61
  /** Which tab opens first. Default "open". */
59
62
  initialStatus?: "open" | "completed" | "all";
@@ -85,6 +88,10 @@ function PriorityPill({ priority }: { priority?: number | null }) {
85
88
  return <StatusPill tone={priority === 1 ? "negative" : priority === 2 ? "warning" : "neutral"}>{PRIORITY[priority] ?? "Low"}</StatusPill>;
86
89
  }
87
90
 
91
+ /** Every filter the tasks API takes besides the status tab: search, priority, type, linked record type, assignee, creator, source meeting; `mine` = assigned to the signed-in person. */
92
+ const FILTER_KEYS = ["search", "priority", "task_type", "entity_type", "assignee_id", "created_by", "source_meeting_id", "mine"] as const;
93
+ type FilterKey = (typeof FILTER_KEYS)[number];
94
+
88
95
  export function TasksScreen(props: TasksScreenProps) {
89
96
  const { pageSize = 25 } = props;
90
97
  const [idParam, setIdParamRaw] = useUrlState("id");
@@ -94,28 +101,35 @@ export function TasksScreen(props: TasksScreenProps) {
94
101
  const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
95
102
  const openId = idParam || null;
96
103
  const [tab, setTab] = useState<Tab>(props.initialStatus ?? "open");
97
- const [search, setSearch] = useState("");
98
- const [priority, setPriority] = useState("");
99
- const [taskType, setTaskType] = useState("");
100
- const [entityType, setEntityType] = useState(props.defaultRecord?.entity_type ?? "");
101
- const q = useDebouncedValue(search);
104
+ // Filters live in the URL (`t_priority=1`, `t_mine=1`), so a filtered view survives reload, Back and sharing.
105
+ const urlFilters = useUrlFilters(FILTER_KEYS, { prefix: "t_" });
106
+ const { draft, filters } = urlFilters;
107
+ const search = draft.search;
108
+ const priority = draft.priority;
109
+ const taskType = draft.task_type;
110
+ const entityType = props.defaultRecord?.entity_type ?? draft.entity_type;
111
+ const q = filters.search;
112
+ // "My tasks" needs the signed-in person's user id: the app passes it as `currentUserId` (the pickers block's
113
+ // useCurrentMember gives it), so this screen holds no team-members scope of its own.
114
+ const assigneeId = draft.mine && props.currentUserId ? props.currentUserId : filters.assignee_id || undefined;
102
115
  const [sheet, setSheet] = useState<"new" | "edit" | null>(null);
103
116
  const [confirmDelete, setConfirmDelete] = useState(false);
104
117
  const [note, setNote] = useState<string | null>(null);
105
118
 
106
- const scope = { status: tab === "all" ? undefined : tab, search: q || undefined, priority: priority || undefined, task_type: taskType || undefined, entity_type: entityType || undefined, entity_id: props.defaultRecord?.entity_id };
119
+ const scope = { status: tab === "all" ? undefined : tab, search: q || undefined, priority: filters.priority || undefined, task_type: filters.task_type || undefined, entity_type: (props.defaultRecord?.entity_type ?? filters.entity_type) || undefined, assignee_id: assigneeId, created_by: filters.created_by || undefined, source_meeting_id: filters.source_meeting_id || undefined, entity_id: props.defaultRecord?.entity_id };
107
120
  const list = usePageQuery<WorkspaceTask>(
108
121
  (page, signal) => g8Get<WorkspaceTask[]>("/tasks", { limit: pageSize, offset: (page - 1) * pageSize, ...scope }, { signal }),
109
- [scope.status, scope.search, scope.priority, scope.task_type, scope.entity_type, scope.entity_id],
122
+ [scope.status, scope.search, scope.priority, scope.task_type, scope.entity_type, scope.entity_id, scope.assignee_id, scope.created_by, scope.source_meeting_id],
110
123
  pageSize,
111
124
  ["tasks", "list", "screen"],
125
+ urlFilters.ready,
112
126
  );
113
127
  const recordScope = { entity_type: props.defaultRecord?.entity_type, entity_id: props.defaultRecord?.entity_id };
114
128
  const openCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "open", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "open", recordScope] });
115
129
  const doneCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "completed", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "completed", recordScope] });
116
130
  const selection = useRowSelection<WorkspaceTask>(JSON.stringify(scope));
117
131
  const reload = () => { void list.refetch(); void openCount.refetch(); void doneCount.refetch(); };
118
- const hasFilters = Boolean(search || priority || taskType || entityType);
132
+ const hasFilters = Boolean(search || priority || taskType || draft.entity_type || draft.assignee_id || draft.created_by || draft.source_meeting_id || draft.mine);
119
133
  const overdue = list.rows.filter(isOverdue).length;
120
134
 
121
135
  const toggle = useG8Mutation((t: WorkspaceTask) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { invalidates: ["tasks"], onSuccess: reload });
@@ -130,7 +144,7 @@ export function TasksScreen(props: TasksScreenProps) {
130
144
  if (openId && gone.includes(openId)) setIdParam(null);
131
145
  reload();
132
146
  });
133
- useEffect(() => { setNote(null); }, [tab, q, priority, taskType, entityType]);
147
+ useEffect(() => { setNote(null); }, [tab, filters]);
134
148
  const allOnPage = list.rows.length > 0 && list.rows.every((r) => selection.selected.has(r.id));
135
149
 
136
150
  return (
@@ -162,21 +176,27 @@ export function TasksScreen(props: TasksScreenProps) {
162
176
  list={
163
177
  <div className="flex flex-col gap-3">
164
178
  <Toolbar
165
- search={{ value: search, onChange: setSearch, placeholder: "Search tasks by title" }}
179
+ search={{ value: search, onChange: (v) => urlFilters.set("search", v), debounceMs: 0, placeholder: "Search tasks by title" }}
166
180
  filters={
167
181
  <>
168
- <select aria-label="Filter by priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => setPriority(e.target.value)}>
182
+ <select aria-label="Filter by priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => urlFilters.set("priority", e.target.value)}>
169
183
  <option value="">Any priority</option><option value="1">High</option><option value="2">Medium</option><option value="3">Low</option>
170
184
  </select>
171
- <select aria-label="Filter by type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) => setTaskType(e.target.value)}>
185
+ <select aria-label="Filter by type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) => urlFilters.set("task_type", e.target.value)}>
172
186
  <option value="">Any type</option>{TASK_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
173
187
  </select>
174
188
  {props.defaultRecord ? null : (
175
- <select aria-label="Filter by linked record" className="h-9 rounded-md border bg-background px-2 text-sm" value={entityType} onChange={(e) => setEntityType(e.target.value)}>
189
+ <select aria-label="Filter by linked record" className="h-9 rounded-md border bg-background px-2 text-sm" value={entityType} onChange={(e) => urlFilters.set("entity_type", e.target.value)}>
176
190
  <option value="">Any record</option>{ENTITY_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
177
191
  </select>
178
192
  )}
179
- {hasFilters ? <Button variant="ghost" size="sm" onClick={() => { setSearch(""); setPriority(""); setTaskType(""); setEntityType(props.defaultRecord?.entity_type ?? ""); }}>Clear filters</Button> : null}
193
+ {props.currentUserId ? <Button variant={draft.mine ? "default" : "outline"} size="sm" aria-pressed={Boolean(draft.mine)} onClick={() => urlFilters.set("mine", draft.mine ? "" : "1")}>My tasks</Button> : null}
194
+ <MoreFilters activeCount={["assignee_id", "created_by", "source_meeting_id"].filter((k) => draft[k as FilterKey]).length}>
195
+ <TextFilter label="assignee ID" placeholder="Assignee user ID" value={draft.assignee_id} onChange={(v) => urlFilters.set("assignee_id", v)} className="w-48" />
196
+ <TextFilter label="creator ID" placeholder="Created by user ID" value={draft.created_by} onChange={(v) => urlFilters.set("created_by", v)} className="w-48" />
197
+ <TextFilter label="meeting ID" placeholder="Source meeting ID" value={draft.source_meeting_id} onChange={(v) => urlFilters.set("source_meeting_id", v)} className="w-48" />
198
+ </MoreFilters>
199
+ {hasFilters ? <Button variant="ghost" size="sm" onClick={urlFilters.clear}>Clear filters</Button> : null}
180
200
  </>
181
201
  }
182
202
  />
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useAllLists: every list of the workspace, for a screen that must look one up by id (its size, its name). The lists API
5
+ * pages (`page`, `limit`), so this reads page after page (100 each, up to 20 pages) instead of one capped `limit=200`
6
+ * call; `truncated` says the page cap was reached. To CHOOSE a list use ListSelect (searchable, paged).
7
+ *
8
+ * SDK operations: GET /lists?page&limit (lists.listLists) read - through lib/read-lists.ts
9
+ */
10
+ import { useAllPages } from "@/g8/hooks/use-all-pages";
11
+ import type { ListSelectOption } from "@/g8/components/list-select";
12
+ import { readLists } from "../lib/read-lists";
13
+
14
+ const PAGE = 100;
15
+
16
+ export function useAllLists(deps: readonly unknown[] = [], enabled = true) {
17
+ return useAllPages<ListSelectOption>((page, signal) => readLists("", page, PAGE, signal), deps, { enabled });
18
+ }
@@ -34,12 +34,14 @@ export function usePageQuery<T>(
34
34
  deps: readonly unknown[],
35
35
  pageSize = 25,
36
36
  cacheKey?: readonly unknown[],
37
+ /** Hold the read (for example until URL filters are known). Default true. */
38
+ enabled = true,
37
39
  ): UsePageQueryResult<T> {
38
40
  const key = JSON.stringify(deps);
39
41
  const [state, setState] = useState({ key, page: 1 });
40
42
  const page = state.key === key ? state.page : 1;
41
43
  const queryKey: G8QueryKey | undefined = cacheKey ? [...cacheKey, { page, pageSize, deps: key }] : undefined;
42
- const query = useG8Query((signal) => fetchPage(page, signal), [key, page], { key: queryKey });
44
+ const query = useG8Query((signal) => fetchPage(page, signal), [key, page], { key: queryKey, enabled });
43
45
 
44
46
  const info = query.data?.pagination as Partial<PageInfo> | undefined;
45
47
  const rows = (query.data?.data ?? []) as T[];
@@ -31,6 +31,8 @@ export interface ContactRecord {
31
31
  last_name?: string | null;
32
32
  full_name?: string | null;
33
33
  work_email?: string | null;
34
+ /** Personal addresses, comma separated. */
35
+ personal_emails?: string | null;
34
36
  direct_phone?: string | null;
35
37
  mobile_phone?: string | null;
36
38
  job_title?: string | null;
@@ -107,6 +109,18 @@ export function contactName(c: Pick<ContactRecord, "first_name" | "last_name" |
107
109
  return joined || c.full_name || (c.work_email && !isMasked(c.work_email) ? c.work_email : "") || (c.id != null ? `#${c.id}` : "-");
108
110
  }
109
111
 
112
+ /**
113
+ * The line under a contact's name: "VP of Sales at Acme". The company is the company's own name (`company.name`, else the
114
+ * list row's `company_name`); it never falls back to anything about the person. "at ..." is left out when there is no
115
+ * title or no company. A company that happens to share the person's name (a freelancer) is shown as the API sent it.
116
+ */
117
+ export function contactSubtitle(c: Pick<ContactRecord, "job_title" | "company_name" | "company">): string {
118
+ const company = [c.company?.name, c.company_name].map((n) => (n ?? "").trim()).find((n) => n !== "");
119
+ const title = (c.job_title ?? "").trim();
120
+ if (title && company) return `${title} at ${company}`;
121
+ return title || company || "No title or company";
122
+ }
123
+
110
124
  /** A company's display name: name, then domain, then `#id`, then a dash. Never "Unnamed". */
111
125
  export function companyName(c: { name?: string | null; domain?: string | null; id?: number | string }): string {
112
126
  return c.name || c.domain || (c.id != null ? `#${c.id}` : "-");
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ /**
4
+ * readLists: one page of audience lists, for ListSelect and useAllLists.
5
+ *
6
+ * SDK operations: GET /lists?search&page&limit (lists.listLists) read
7
+ */
8
+ import { g8Get } from "@/g8/lib/g8-client";
9
+ import { hasNextOf } from "@/g8/lib/paging";
10
+ import type { ListSelectOption, ListsReader } from "@/g8/components/list-select";
11
+
12
+ export const readLists: ListsReader = async (q, page, pageSize, signal) => {
13
+ const env = await g8Get<ListSelectOption[]>("/lists", { search: q || undefined, page, limit: pageSize }, { signal });
14
+ const rows = (env.data ?? []) as ListSelectOption[];
15
+ return { rows, hasNext: hasNextOf(env, pageSize, rows.length) };
16
+ };