@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
@@ -18,6 +18,7 @@
18
18
  import { useEffect, useState } from "react";
19
19
  import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Skeleton } from "@graph8/sdk/ui";
20
20
  import { ApiError } from "@/g8/components/api-error";
21
+ import { ListSelect } from "@/g8/crm/components/list-select";
21
22
  import { g8Get, g8Post } from "@/g8/lib/g8-client";
22
23
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
24
  import { useG8Query } from "@/g8/hooks/use-g8-query";
@@ -29,7 +30,6 @@ export interface FormMappingEditorProps {
29
30
  onSaved?: () => void;
30
31
  }
31
32
 
32
- interface ListOption { id: number; title: string }
33
33
 
34
34
  const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
35
35
 
@@ -44,9 +44,9 @@ function FormMappingEditorFor({ form, onSaved }: FormMappingEditorProps) {
44
44
  const overview = useG8Query((signal) => g8Get<FormOverview>(`${base}/overview`, q, { signal }).then((r) => r.data), [base, form.source_id]);
45
45
  const fields = useG8Query((signal) => g8Get(`${base}/fields`, q, { signal }).then((r) => pickItems<{ name: string }>(r.data, "items", "fields")), [base, form.source_id]);
46
46
  const targets = useG8Query((signal) => g8Get<{ groups?: ContactFieldGroup[] }>("/contacts/form-fields", {}, { signal }).then((r) => r.data?.groups ?? []), []);
47
- const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }).then((r) => r.data ?? []), []);
48
47
  const [rows, setRows] = useState<MappingRow[]>([]);
49
48
  const [listId, setListId] = useState<number | undefined>(undefined);
49
+ const [pickedTitle, setPickedTitle] = useState<string | undefined>(undefined);
50
50
  const [seeded, setSeeded] = useState(false);
51
51
 
52
52
  useEffect(() => {
@@ -60,7 +60,7 @@ function FormMappingEditorFor({ form, onSaved }: FormMappingEditorProps) {
60
60
  setSeeded(true);
61
61
  }, [overview.data, fields.data, seeded]);
62
62
 
63
- const listName = (lists.data ?? []).find((l) => l.id === listId)?.title ?? overview.data?.destination?.list_name ?? "";
63
+ const listName = pickedTitle ?? overview.data?.destination?.list_name ?? "";
64
64
  const body = mappingPayload(form.source_id, rows, listName, listId, overview.data?.route?.version ?? overview.data?.destination?.route_version);
65
65
  const ready = Boolean(listId) && body.form_to_contact_field_mapping.length > 0;
66
66
 
@@ -75,7 +75,7 @@ function FormMappingEditorFor({ form, onSaved }: FormMappingEditorProps) {
75
75
  const test = useG8Mutation(async () => (await g8Post(`${base}/route/test`, { source_id: form.source_id })).data);
76
76
 
77
77
  const set = (i: number, patch: Partial<MappingRow>): void => setRows((prev) => prev.map((r, at) => (at === i ? { ...r, ...patch } : r)));
78
- const loadError = overview.error ?? fields.error ?? targets.error ?? lists.error;
78
+ const loadError = overview.error ?? fields.error ?? targets.error;
79
79
  if (loadError) return <ApiError error={loadError} title="Could not load the form setup" onRetry={() => { void overview.refetch(); void fields.refetch(); }} />;
80
80
  if (!seeded) return <div className="grid gap-2" aria-busy="true"><Skeleton className="h-9 w-64" /><Skeleton className="h-32 w-full" /></div>;
81
81
 
@@ -86,10 +86,7 @@ function FormMappingEditorFor({ form, onSaved }: FormMappingEditorProps) {
86
86
  <CardContent className="grid gap-4">
87
87
  <div className="max-w-sm">
88
88
  <Label htmlFor="g8-form-list" className="mb-1">Save leads to list</Label>
89
- <select id="g8-form-list" className={SELECT} value={listId ?? ""} onChange={(e) => setListId(e.target.value ? Number(e.target.value) : undefined)}>
90
- <option value="">Choose a list...</option>
91
- {(lists.data ?? []).map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
92
- </select>
89
+ <ListSelect id="g8-form-list" label="Save leads to list" emptyOption="Choose a list..." pageSize={200} value={listId} valueLabel={overview.data?.destination?.list_name ?? undefined} selectClassName={SELECT} onChange={(l) => { setListId(l ? l.id : undefined); setPickedTitle(l ? l.title : undefined); }} />
93
90
  </div>
94
91
  {rows.length === 0 ? <p className="text-muted-foreground text-sm">No fields have been captured for this form yet. Submit it once on your site, then reload.</p> : (
95
92
  <div className="grid gap-2">
@@ -114,7 +111,7 @@ function FormMappingEditorFor({ form, onSaved }: FormMappingEditorProps) {
114
111
  <Button variant="ghost" size="sm" loading={test.loading} disabled={!overview.data?.destination?.list_id} onClick={() => void test.mutate(undefined)}>Send a test submission</Button>
115
112
  </div>
116
113
  {[suggest.error, preview.error, save.error, test.error].filter(Boolean).map((e, i) => <ApiError key={i} error={e} title="That did not work" />)}
117
- {save.data ? <p role="status" className="text-sm text-green-700">Mapping saved. New submissions follow it.</p> : null}
114
+ {save.data ? <p role="status" className="text-sm text-positive-foreground">Mapping saved. New submissions follow it.</p> : null}
118
115
  {test.data ? <p role="status" className="text-sm">Test submission sent to the list (one synthetic contact was added). {cellText(test.data).slice(0, 200)}</p> : null}
119
116
  {previewRows.length > 0 ? (
120
117
  <div className="rounded border p-3 text-xs"><p className="mb-1 font-medium">Preview (nothing saved)</p>{previewRows.map((r, i) => <pre key={i} className="overflow-x-auto">{JSON.stringify(r)}</pre>)}</div>
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ /**
4
+ * FormRouteHistory: undo a routing change. Restore the route the form had before its latest save, or stop routing the
5
+ * form altogether. The form keeps CAPTURING either way; only where leads are delivered changes.
6
+ *
7
+ * SDK operations, in the order the screen uses them:
8
+ * 1. getFormOverview GET /forms/{form_key}/overview?source_id (read; the current route version and destination)
9
+ * 2. rollbackFormRoute DELETE /forms/{form_key}/route { source_id, expected_current_version } (DESTRUCTIVE, confirmed: restores the previous route version)
10
+ * 3. rollbackFormRoute DELETE /forms/{form_key}/route { source_id, cancel_current: true, expected_current_version } (DESTRUCTIVE, confirmed: removes the route)
11
+ *
12
+ * Stopping routing sends the version the screen last read, so a change made in the meantime is refused (409). Restoring the
13
+ * previous route is NOT version-checked by the API: it always restores the version before the current one, so look at the
14
+ * route version shown here before you confirm. A refused request reloads the route and says what happened.
15
+ */
16
+ import { Button, Skeleton } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
19
+ import { useConfirmAction } from "@/g8/components/confirmed-action";
20
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
21
+ import { SectionCard } from "@/g8/components/screen/section-card";
22
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
23
+ import { g8Delete, g8Get } from "@/g8/lib/g8-client";
24
+ import type { FormOverview, FormRef } from "../lib/form-types";
25
+
26
+ export interface FormRouteHistoryProps {
27
+ form: FormRef;
28
+ /** Called after the route changed, so the list and overview refresh. */
29
+ onChanged?: () => void;
30
+ className?: string;
31
+ }
32
+
33
+ export function FormRouteHistory({ form, onChanged, className }: FormRouteHistoryProps) {
34
+ const base = `/forms/${encodeURIComponent(form.form_key)}`;
35
+ const { ask, dialog, failure } = useConfirmAction();
36
+ const overview = useG8Query((signal) => g8Get<FormOverview>(`${base}/overview`, { source_id: form.source_id }, { signal }).then((r) => r.data), [form.form_key, form.source_id]);
37
+ const version = overview.data?.route?.version ?? undefined;
38
+ const list = overview.data?.destination?.list_name;
39
+ const done = (): void => { void overview.refetch(); onChanged?.(); };
40
+ const conflict = (err: unknown): unknown => {
41
+ if ((err as { status?: number })?.status !== 409) return err;
42
+ void overview.refetch();
43
+ return new Error("The API refused this: the route changed since it was loaded, or there is no earlier version to restore. The route shown here was reloaded: check it and try again.");
44
+ };
45
+
46
+ return (
47
+ <SectionCard className={className} title="Undo a routing change" description="Where this form's leads are delivered.">
48
+ {overview.error ? <ApiError error={overview.error} title="Could not load the route" onRetry={() => void overview.refetch()} /> : !overview.data ? <Skeleton className="h-16 w-full" aria-busy="true" /> : version === undefined ? (
49
+ <p className="text-sm">This form has no destination route to undo.</p>
50
+ ) : (
51
+ <div className="grid gap-3">
52
+ <KeyValueList columns={2} items={[{ label: "Route version", value: String(version) }, { label: "Destination list", value: list ?? "" }]} />
53
+ <div className="flex flex-wrap gap-2">
54
+ <Button variant="outline" disabled={version < 2} onClick={() => ask({
55
+ tier: "destructive", title: "Restore the previous route", confirmLabel: "Restore previous route",
56
+ description: `Replaces the current destination and field mapping with the ones this form had before its latest save. New leads follow the restored route; leads already delivered are not moved.`,
57
+ mapError: conflict, run: () => g8Delete(`${base}/route`, { body: { source_id: form.source_id } }), onDone: done,
58
+ })}>Restore the previous route</Button>
59
+ <Button variant="ghost" onClick={() => ask({
60
+ tier: "destructive", title: "Stop routing this form", confirmLabel: "Stop routing",
61
+ description: "Removes the destination and mapping. The form keeps capturing submissions, but they are not delivered to any list until you set a destination again.",
62
+ mapError: conflict, run: () => g8Delete(`${base}/route`, { body: { source_id: form.source_id, cancel_current: true, expected_current_version: version } }), onDone: done,
63
+ })}>Stop routing this form</Button>
64
+ </div>
65
+ {version < 2 ? <p className="text-muted-foreground text-xs">There is no earlier route to restore.</p> : null}
66
+ </div>
67
+ )}
68
+ {failure ? <ApiError error={failure} title="That did not work" onRetry={() => void overview.refetch()} /> : null}
69
+ <ConfirmSpendDialog {...dialog} />
70
+ </SectionCard>
71
+ );
72
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ /**
4
+ * FormSavedViews: save the current search and health filter of the forms list under a name, and bring it back with one
5
+ * click.
6
+ *
7
+ * SDK operations:
8
+ * 1. listSavedFormViews GET /forms/views/saved (read)
9
+ * 2. saveFormView PUT /forms/views/saved/{view_id} { name, filters } (write; a new view gets a fresh id, saving over a view keeps its id)
10
+ *
11
+ * The filters saved are the ones this list has: `search` and, for failing / needs setup / live, `saved_view`. Applying a
12
+ * view hands those back to the screen; nothing is written when a view is applied.
13
+ */
14
+ import { useState } from "react";
15
+ import { Button, Input } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
18
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
19
+ import { g8Get, g8Put } from "@/g8/lib/g8-client";
20
+ import { asRec, rowsOf, str } from "../lib/forms-extra";
21
+ import type { Rec } from "../lib/forms-extra";
22
+
23
+ export interface FormSavedViewsProps {
24
+ /** The list's current search text. */
25
+ search: string;
26
+ /** The list's current health filter ("" for all). */
27
+ health: string;
28
+ /** Apply a saved view back to the list. */
29
+ onApply: (view: { search: string; health: string }) => void;
30
+ className?: string;
31
+ }
32
+
33
+ /** Health values the API's `saved_view` filter understands. */
34
+ const HEALTH_VIEWS = new Set(["failing", "needs_setup", "live"]);
35
+
36
+ /** What a saved `saved_view` becomes in this list's health filter; values the list has no filter for map to "all". */
37
+ export function healthOfView(view: string): string {
38
+ if (view === "failing" || view === "failures") return "failing";
39
+ if (view === "needs_setup") return "needs_setup";
40
+ if (view === "live" || view === "healthy") return "live";
41
+ return "";
42
+ }
43
+
44
+ /** The list filters a view cannot hold (paused, no data): saving would silently drop them. */
45
+ export const UNSAVABLE_HEALTH = new Set(["paused", "no_data"]);
46
+
47
+ export function savedFilters(search: string, health: string): Rec {
48
+ return { ...(search.trim() ? { search: search.trim() } : {}), ...(HEALTH_VIEWS.has(health) ? { saved_view: health } : {}) };
49
+ }
50
+
51
+ function newViewId(): string {
52
+ return globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : `view-${Date.now().toString(36)}`;
53
+ }
54
+
55
+ export function FormSavedViews({ search, health, onApply, className }: FormSavedViewsProps) {
56
+ const views = useG8Query((signal) => g8Get("/forms/views/saved", undefined, { signal }), []);
57
+ const rows = rowsOf<Rec>(views.data?.data, "views");
58
+ const [name, setName] = useState("");
59
+ const filters = savedFilters(search, health);
60
+ const existing = rows.find((v) => str(v.name) === name.trim());
61
+ // Saving under a name that already exists keeps that view's id (an update); a new name gets a fresh id.
62
+ const save = useG8Mutation(() => g8Put(`/forms/views/saved/${encodeURIComponent(existing ? str(existing.id) : newViewId())}`, { name: name.trim(), filters }), {
63
+ onSuccess: () => { setName(""); void views.refetch(); },
64
+ });
65
+ return (
66
+ <div className={className}>
67
+ <div className="flex flex-wrap items-center gap-2">
68
+ {rows.map((v) => {
69
+ const f = asRec(v.filters);
70
+ return <Button key={str(v.id)} size="sm" variant="outline" onClick={() => onApply({ search: str(f.search), health: healthOfView(str(f.saved_view)) })}>{str(v.name)}</Button>;
71
+ })}
72
+ <Input aria-label="Name for this view" className="h-8 w-40" placeholder="Save this view as" value={name} onChange={(e) => setName(e.target.value)} />
73
+ <Button size="sm" variant="outline" loading={save.loading} disabled={name.trim() === "" || Object.keys(filters).length === 0 || UNSAVABLE_HEALTH.has(health)} onClick={() => void save.mutate(undefined)}>Save view</Button>
74
+ </div>
75
+ {UNSAVABLE_HEALTH.has(health) ? <p role="note" className="text-muted-foreground mt-1 text-xs">A view cannot hold the Paused or No data filter. Pick another health, or clear it, to save this view.</p> : null}
76
+ {Object.keys(filters).length === 0 && name.trim() !== "" ? <p className="text-muted-foreground mt-1 text-xs">Set a search or a health filter first: a view with no filters would show everything.</p> : null}
77
+ {views.error ? <p role="note" className="text-muted-foreground mt-1 text-xs">Saved views could not be loaded. <button type="button" className="underline" onClick={() => void views.refetch()}>Reload views</button></p> : null}
78
+ {save.error ? <ApiError error={save.error} title="Could not save the view" /> : null}
79
+ </div>
80
+ );
81
+ }
@@ -9,12 +9,16 @@
9
9
  * Composes: ScreenLayout (title, tabs, stats), Toolbar (debounced search, health filter), SplitView +
10
10
  * DetailPanel (selected form, deep link `?id=<source_id>/<form_key>`, one submission `&submission=<id>`), FormEmbed
11
11
  * (Install tab, `?tab=install`), SiteAuditPanel (Site audit tab, `?tab=audit`), FormMappingEditor (Setup tab),
12
- * SubmissionsTable (Submissions tab).
12
+ * SubmissionsTable + SubmissionsExport (Submissions tab), FormBackfill + FormRouteHistory (Routing tab), FormActivity
13
+ * (Activity tab), FormGroups (Groups tab, `?tab=groups`), FormSavedViews (saved search and health filters).
13
14
  *
14
15
  * User flows: install the snippet and copy the ready-made lead form, see every captured form with its health and
15
16
  * counts, search and filter, open one to read capture and delivery numbers, rename it, pause or resume delivery
16
17
  * (confirmed), pick the destination list and map fields (suggest, preview, save, test), read submissions, trace
17
- * where a lead went and retry a failed delivery (confirmed), and find forms graph8 is not capturing.
18
+ * where a lead went and retry a failed delivery (confirmed), export submissions to CSV, deliver earlier leads through
19
+ * the route (preview, then a confirmed delivery), undo a routing change (confirmed), read a form's activity and the pages
20
+ * it appears on, group related forms, save a filtered view, load every form past the first page, and find forms graph8
21
+ * is not capturing. Creating a form is not possible: forms are captured from your site (an API limit, not a missing screen).
18
22
  *
19
23
  * SDK operations: listForms, getFormOverview, updateFormRegistry, pauseFormDelivery / resumeFormDelivery
20
24
  * (external, confirmed), searchFormSubmissions (read, to resolve a `&submission=` deep link that was not clicked
@@ -41,11 +45,20 @@ import { useUrlState } from "@/g8/hooks/use-url-state";
41
45
  import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
42
46
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
43
47
  import { useG8Query } from "@/g8/hooks/use-g8-query";
48
+ import { usePaginated } from "@/g8/hooks/use-paginated";
49
+ import { pageOf } from "../lib/forms-extra";
44
50
  import { STATUS_LABEL, cellText, formLabel, formStatus, pageUrlText, pickItems, submissionFailed, submissionState } from "../lib/form-types";
45
51
  import type { FormListItem, FormOverview, FormRef, FormSubmission } from "../lib/form-types";
46
52
  import { FormEmbed } from "./form-embed";
53
+ import { FormActivity } from "./form-activity";
54
+ import { FormBackfill } from "./form-backfill";
55
+ import { FormGroups } from "./form-groups";
47
56
  import { FormMappingEditor } from "./form-mapping-editor";
57
+ import { FormRouteHistory } from "./form-route-history";
58
+ import { FormSavedViews } from "./form-saved-views";
59
+ import { MoreRows } from "./more-rows";
48
60
  import { SiteAuditPanel } from "./site-audit-panel";
61
+ import { SubmissionsExport } from "./submissions-export";
49
62
  import { SubmissionsTable } from "./submissions-table";
50
63
  import { pluralWord } from "@/g8/lib/plural";
51
64
  import { useBrand } from "@/g8/hooks/use-brand";
@@ -58,7 +71,7 @@ export interface FormsScreenProps {
58
71
  className?: string;
59
72
  }
60
73
 
61
- type Tab = "forms" | "install" | "audit";
74
+ type Tab = "forms" | "install" | "audit" | "groups";
62
75
  const LIMIT = 100;
63
76
  /** How many of a form's latest submissions a deep link is looked up in. */
64
77
  const SUBMISSION_LOOKUP = 100;
@@ -208,7 +221,7 @@ function SubmissionDetail({ form, submission, onChanged }: { form: FormRef; subm
208
221
  export function FormsScreen({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: FormsScreenProps) {
209
222
  const brand = useBrand();
210
223
  const [tabParam, setTabParam] = useUrlState("tab", "forms");
211
- const tab: Tab = tabParam === "install" || tabParam === "audit" ? tabParam : "forms";
224
+ const tab: Tab = tabParam === "install" || tabParam === "audit" || tabParam === "groups" ? tabParam : "forms";
212
225
  const setTab = (next: Tab): void => setTabParam(next === "forms" ? null : next);
213
226
  const [selectedKey, setSelectedKey] = useUrlState("id", "");
214
227
  const [view, setView] = useUrlState("view", "");
@@ -227,12 +240,18 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
227
240
  }, [text]);
228
241
 
229
242
  const closeForm = (): void => { setView(null); setSelectedKey(null); setSubmissionId(null); setOpenedSubmission(null); };
230
- const forms = useG8Query(
231
- (signal) => g8Get<{ items?: FormListItem[]; total?: number }>("/forms", { page: 1, per_page: LIMIT, search: search || undefined }, { signal }).then((r) => ({ items: pickItems<FormListItem>(r.data, "items"), total: (r.data as { total?: number } | undefined)?.total ?? 0 })),
232
- [search],
233
- );
234
- const items = forms.data?.items ?? [];
235
- const total = forms.data?.total ?? items.length;
243
+ // Every page of forms is reachable: the list loads LIMIT at a time and "Load more" appends the next page.
244
+ const [apiTotal, setTotal] = useState(0);
245
+ const forms = usePaginated<FormListItem>(async (cursor, signal) => {
246
+ const page = cursor ? Number(cursor) : 1;
247
+ const r = await g8Get<{ items?: FormListItem[]; total?: number }>("/forms", { page, per_page: LIMIT, search: search || undefined }, { signal });
248
+ const got = pickItems<FormListItem>(r.data, "items");
249
+ const all = (r.data as { total?: number } | undefined)?.total ?? 0;
250
+ setTotal(all);
251
+ return pageOf(got, got.length > 0 && page * LIMIT < all ? page + 1 : null);
252
+ }, [search]);
253
+ const items = forms.items;
254
+ const total = apiTotal || items.length;
236
255
  const shown = items.filter((f) => !health || formStatus(f) === health);
237
256
  const ref = parseRef(selectedKey);
238
257
  const selected: FormListItem | undefined = ref ? (items.find((f) => f.form_key === ref.form_key && f.source_id === ref.source_id) ?? { id: `${ref.source_id}:${ref.form_key}`, ...ref }) : undefined;
@@ -250,7 +269,7 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
250
269
  );
251
270
  const linkedSubmission: FormSubmission | null = !submissionId ? null : openedSubmission?.id === submissionId ? openedSubmission : ((lookup.data ?? []).find((x) => x.id === submissionId) ?? null);
252
271
 
253
- const rows = forms.error ? <ApiError error={forms.error} title="Could not load forms" onRetry={() => void forms.refetch()} /> : forms.loading && !forms.data ? (
272
+ const rows = forms.error ? <ApiError error={forms.error} title="Could not load forms" onRetry={() => void forms.reload()} /> : forms.loading && forms.items.length === 0 ? (
254
273
  <div aria-busy="true" className="grid gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>
255
274
  ) : shown.length === 0 ? (
256
275
  <EmptyState
@@ -278,7 +297,7 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
278
297
  </li>
279
298
  );
280
299
  })}
281
- {total > items.length ? <li className="text-muted-foreground text-xs">Showing the first {items.length} of {total.toLocaleString()} forms. Search to narrow down.</li> : null}
300
+ {forms.hasMore ? <li><MoreRows shown={items.length} noun={`of ${total.toLocaleString()} forms`} hasMore loading={forms.loadingMore} onMore={() => void forms.loadMore()} /></li> : null}
282
301
  </ul>
283
302
  );
284
303
 
@@ -291,9 +310,21 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
291
310
  onClose={() => closeForm()}
292
311
  {...expandProps}
293
312
  tabs={[
294
- { id: "overview", label: "Overview", content: <FormOverviewTab key={refKey(selected)} form={selected} onChanged={() => void forms.refetch()} /> },
295
- { id: "setup", label: "Destination and fields", content: <FormMappingEditor key={refKey(selected)} form={selected} onSaved={() => void forms.refetch()} /> },
296
- { id: "submissions", label: "Submissions", content: <SubmissionsTable key={`${refKey(selected)}:${subsRefresh}`} form={selected} selectedId={submissionId || undefined} onOpen={(sub) => { setOpenedSubmission(sub); setSubmissionId(sub.id); }} /> },
313
+ { id: "overview", label: "Overview", content: <FormOverviewTab key={refKey(selected)} form={selected} onChanged={() => void forms.reload()} /> },
314
+ { id: "setup", label: "Destination and fields", content: <FormMappingEditor key={refKey(selected)} form={selected} onSaved={() => void forms.reload()} /> },
315
+ { id: "submissions", label: "Submissions", content: (
316
+ <div className="grid gap-3">
317
+ <SubmissionsExport form={selected} />
318
+ <SubmissionsTable key={`${refKey(selected)}:${subsRefresh}`} form={selected} selectedId={submissionId || undefined} onOpen={(sub) => { setOpenedSubmission(sub); setSubmissionId(sub.id); }} />
319
+ </div>
320
+ ) },
321
+ { id: "routing", label: "Routing", content: (
322
+ <div className="grid gap-4">
323
+ <FormBackfill key={`b${refKey(selected)}`} form={selected} />
324
+ <FormRouteHistory key={`r${refKey(selected)}`} form={selected} onChanged={() => void forms.reload()} />
325
+ </div>
326
+ ) },
327
+ { id: "activity", label: "Activity", content: <FormActivity key={refKey(selected)} form={selected} /> },
297
328
  ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
298
329
  ]}
299
330
  />
@@ -305,7 +336,7 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
305
336
  title="Forms"
306
337
  description="Capture the forms already on your site, route every lead to a list, and watch what arrives."
307
338
  icon={<ClipboardList className="size-5" />}
308
- tabs={[{ id: "forms", label: "Forms", count: total }, { id: "install", label: "Install" }, { id: "audit", label: "Site audit" }]}
339
+ tabs={[{ id: "forms", label: "Forms", count: total }, { id: "groups", label: "Groups" }, { id: "install", label: "Install" }, { id: "audit", label: "Site audit" }]}
309
340
  activeTab={tab}
310
341
  onTabChange={(id) => { setTab(id as Tab); closeForm(); }}
311
342
  stats={tab === "forms" ? [
@@ -315,6 +346,7 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
315
346
  { label: "Need setup", value: items.filter((f) => formStatus(f) === "needs_setup").length.toLocaleString(), tone: "info" },
316
347
  ] : undefined}
317
348
  toolbar={tab === "forms" ? (
349
+ <>
318
350
  <Toolbar
319
351
  search={{ value: text, onChange: setText, placeholder: "Search forms" }}
320
352
  filters={(
@@ -325,6 +357,8 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
325
357
  )}
326
358
  actions={toolbarActions}
327
359
  />
360
+ <FormSavedViews className="mt-2" search={text} health={health} onApply={(v) => { setText(v.search); setSearch(v.search.trim()); setHealth(v.health); }} />
361
+ </>
328
362
  ) : undefined}
329
363
  >
330
364
  <FormSheet
@@ -345,7 +379,7 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
345
379
  )
346
380
  ) : null}
347
381
  </FormSheet>
348
- {tab === "install" ? <FormEmbed /> : tab === "audit" ? <SiteAuditPanel /> : (
382
+ {tab === "install" ? <FormEmbed /> : tab === "audit" ? <SiteAuditPanel /> : tab === "groups" ? <FormGroups /> : (
349
383
  <SplitView list={rows} detail={detail} detailOpen={Boolean(selected)} onCloseDetail={() => closeForm()} expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)} detailWidth="xl" />
350
384
  )}
351
385
  </ScreenLayout>
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MoreRows: the honest end of a list that the API pages. Shows how many rows are on screen and, while the API has
5
+ * another page, a "Load more" button. A list never ends silently: either every row is reachable or this says there are more.
6
+ * (Same contract as the foundation's LoadMore; kept local so the forms block does not wait on it.)
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import { Button } from "@graph8/sdk/ui";
11
+
12
+ export interface MoreRowsProps {
13
+ /** Rows on screen. */
14
+ shown: number;
15
+ /** What the rows are, plural: "people". */
16
+ noun: string;
17
+ hasMore: boolean;
18
+ loading?: boolean;
19
+ onMore: () => void;
20
+ }
21
+
22
+ export function MoreRows({ shown, noun, hasMore, loading, onMore }: MoreRowsProps) {
23
+ if (!hasMore) return null;
24
+ return (
25
+ <div className="text-muted-foreground flex items-center justify-between gap-3 py-2 text-sm">
26
+ <span role="status">Showing {shown.toLocaleString()} {noun}</span>
27
+ <Button type="button" variant="outline" size="sm" loading={loading} onClick={onMore}>Load more</Button>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SubmissionsExport: download a form's submissions as a CSV file (one column per submitted field).
5
+ *
6
+ * SDK operations:
7
+ * 1. searchFormSubmissions POST /forms/{form_key}/submissions { page, per_page: 100, source_id } (read; every page is fetched in turn)
8
+ *
9
+ * The export fetches up to `maxRows` submissions (default 5,000) and says so when the form has more: a truncated file
10
+ * is never presented as the whole thing. Identifying fields are redacted for keys without the Signals write capability,
11
+ * and the file carries the redacted values. Cells that start with = + - or @ are prefixed with ' so a spreadsheet does
12
+ * not run them as formulas.
13
+ */
14
+ import { useState } from "react";
15
+ import { Download } from "lucide-react";
16
+ import { Button } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
19
+ import { g8Post } from "@/g8/lib/g8-client";
20
+ import { submissionState } from "../lib/form-types";
21
+ import type { FormRef, FormSubmission } from "../lib/form-types";
22
+ import { submissionsCsv } from "../lib/forms-extra";
23
+
24
+ export interface SubmissionsExportProps {
25
+ form: FormRef;
26
+ /** The most submissions one file holds. Default 5000. */
27
+ maxRows?: number;
28
+ className?: string;
29
+ }
30
+
31
+ const PER_PAGE = 100;
32
+
33
+ export function SubmissionsExport({ form, maxRows = 5000, className }: SubmissionsExportProps) {
34
+ const [note, setNote] = useState("");
35
+ const run = useG8Mutation(async () => {
36
+ const rows: FormSubmission[] = [];
37
+ let total = 0;
38
+ for (let page = 1; rows.length < maxRows; page += 1) {
39
+ setNote(`Collecting submissions: ${rows.length.toLocaleString()}${total ? ` of ${total.toLocaleString()}` : ""}`);
40
+ const res = await g8Post<unknown>(`/forms/${encodeURIComponent(form.form_key)}/submissions`, { page, per_page: PER_PAGE, source_id: form.source_id });
41
+ const d = res.data as { submissions?: { items?: FormSubmission[]; total?: number }; items?: FormSubmission[]; total?: number } | undefined;
42
+ const block = d?.submissions ?? d;
43
+ const items = block?.items ?? [];
44
+ total = block?.total ?? total;
45
+ rows.push(...items);
46
+ if (items.length < PER_PAGE || (total && rows.length >= total)) break;
47
+ }
48
+ const file = rows.slice(0, maxRows);
49
+ const csv = submissionsCsv(file.map((s) => ({ id: s.id, received_at: s.received_at, is_test: s.is_test, delivery_state: submissionState(s), form_data: s.form_data })));
50
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
51
+ const a = document.createElement("a");
52
+ a.href = url;
53
+ a.download = `${form.form_key.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase() || "form"}-submissions.csv`;
54
+ a.click();
55
+ URL.revokeObjectURL(url);
56
+ setNote(total > file.length ? `Downloaded the first ${file.length.toLocaleString()} of ${total.toLocaleString()} submissions. The file does not hold the rest.` : `Downloaded ${file.length.toLocaleString()} submissions.`);
57
+ });
58
+ return (
59
+ <div className={className}>
60
+ <div className="flex items-center gap-3">
61
+ <Button size="sm" variant="outline" loading={run.loading} onClick={() => void run.mutate(undefined)}><Download className="size-4" />Export CSV</Button>
62
+ {note ? <span role="status" className="text-muted-foreground text-xs">{note}</span> : null}
63
+ </div>
64
+ {run.error ? <ApiError error={run.error} title="Could not export the submissions" /> : null}
65
+ </div>
66
+ );
67
+ }
@@ -18,6 +18,7 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
18
18
  import { EmptyState } from "@/g8/components/empty-state";
19
19
  import { g8Get, g8Post } from "@/g8/lib/g8-client";
20
20
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
21
+ import { useAllPages } from "@/g8/hooks/use-all-pages";
21
22
  import { useG8Query } from "@/g8/hooks/use-g8-query";
22
23
  import { cellText, pickItems, submissionFailed, submissionState } from "../lib/form-types";
23
24
  import type { FormRef, FormSubmission } from "../lib/form-types";
@@ -64,19 +65,27 @@ function SubmissionTrace({ form, submission, onClose }: { form: FormRef; submiss
64
65
  );
65
66
  }
66
67
 
68
+ /** Forms per read: every page is read, so the form picker offers all of them. */
69
+ const FORM_PAGE = 100;
70
+
67
71
  export function SubmissionsTable({ form: given, pageSize = 20, maxFieldColumns = 4, onOpen, selectedId }: SubmissionsTableProps) {
68
72
  const brand = useBrand();
69
73
  const [picked, setPicked] = useState<FormRef | undefined>(undefined);
70
- const forms = useG8Query((signal) => g8Get<unknown>("/forms", { per_page: 100 }, { signal }).then((r) => pickItems<FormRef & { display_name?: string | null }>(r.data, "items")), [], { enabled: !given });
74
+ const forms = useAllPages<FormRef & { display_name?: string | null }>(async (page, signal) => {
75
+ const r = await g8Get<unknown>("/forms", { page, per_page: FORM_PAGE }, { signal });
76
+ const rows = pickItems<FormRef & { display_name?: string | null }>(r.data, "items");
77
+ const total = (r.data as { total?: number } | undefined)?.total;
78
+ return { rows, hasNext: typeof total === "number" ? page * FORM_PAGE < total : rows.length >= FORM_PAGE };
79
+ }, [], { enabled: !given });
71
80
  const form = given ?? picked;
72
81
  if (!form) {
73
82
  if (forms.error) return <ApiError error={forms.error} title="Could not load forms" onRetry={() => void forms.refetch()} />;
74
- if (forms.loading && !forms.data) return <Skeleton className="h-9 w-64" aria-busy="true" />;
75
- if ((forms.data ?? []).length === 0) return <EmptyState title="No forms captured yet" description={`Install the ${brand.linkLabel} snippet on your site; forms appear after their first submission.`} />;
83
+ if (forms.loading && !forms.loaded) return <Skeleton className="h-9 w-64" aria-busy="true" />;
84
+ if (forms.items.length === 0) return <EmptyState title="No forms captured yet" description={`Install the ${brand.linkLabel} snippet on your site; forms appear after their first submission.`} />;
76
85
  return (
77
- <select aria-label="Choose a form" className="border-input bg-background h-9 max-w-sm rounded-md border px-2 text-sm" defaultValue="" onChange={(e) => { const f = forms.data?.[Number(e.target.value)]; if (f) setPicked({ form_key: f.form_key, source_id: f.source_id }); }}>
86
+ <select aria-label="Choose a form" className="border-input bg-background h-9 max-w-sm rounded-md border px-2 text-sm" defaultValue="" onChange={(e) => { const f = forms.items[Number(e.target.value)]; if (f) setPicked({ form_key: f.form_key, source_id: f.source_id }); }}>
78
87
  <option value="" disabled>Choose a form</option>
79
- {(forms.data ?? []).map((f, i) => <option key={`${f.source_id}:${f.form_key}`} value={i}>{f.display_name || f.form_key}</option>)}
88
+ {forms.items.map((f, i) => <option key={`${f.source_id}:${f.form_key}`} value={i}>{f.display_name || f.form_key}</option>)}
80
89
  </select>
81
90
  );
82
91
  }
@@ -136,7 +145,7 @@ function SubmissionsFor({ form, pageSize, maxFieldColumns, onOpen, selectedId }:
136
145
  ) : <>{s.received_at ? new Date(s.received_at).toLocaleString() : ""}{s.is_test ? " (test)" : ""}</>}
137
146
  </TableCell>
138
147
  {columns.map((c) => <TableCell key={c}>{cellText(s.form_data?.[c])}</TableCell>)}
139
- <TableCell className={submissionFailed(s) ? "text-red-700" : undefined}>{submissionState(s)}</TableCell>
148
+ <TableCell className={submissionFailed(s) ? "text-error-foreground" : undefined}>{submissionState(s)}</TableCell>
140
149
  <TableCell className="text-right">
141
150
  <Button size="sm" variant="ghost" onClick={() => setTraceOf(s)}>Trace</Button>
142
151
  {submissionFailed(s) && s.delivery?.receipt_id ? <Button size="sm" variant="ghost" onClick={() => { retry.reset(); setRetrying(s); }}>Retry</Button> : null}
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Helpers for the forms management components (groups, activity, backfill, route history, saved views, export).
5
+ * These endpoints return open dicts, so every reader accepts what is present and ignores the rest.
6
+ */
7
+ import type { PaginatedResponse } from "@graph8/sdk";
8
+
9
+ export type Rec = Record<string, unknown>;
10
+
11
+ export function asRec(value: unknown): Rec {
12
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Rec) : {};
13
+ }
14
+
15
+ export function str(value: unknown, fallback = ""): string {
16
+ return typeof value === "string" ? value : value == null ? fallback : String(value);
17
+ }
18
+
19
+ export function num(value: unknown): number | undefined {
20
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
21
+ }
22
+
23
+ export function rowsOf<T = Rec>(data: unknown, ...keys: string[]): T[] {
24
+ if (Array.isArray(data)) return data as T[];
25
+ const rec = asRec(data);
26
+ for (const key of [...keys, "items"]) if (Array.isArray(rec[key])) return rec[key] as T[];
27
+ return [];
28
+ }
29
+
30
+ /** Wrap rows and the API's "next" marker (a page number, cursor or offset; null when there is no more) for `usePaginated`. */
31
+ export function pageOf<T>(rows: T[], next: unknown): PaginatedResponse<T> {
32
+ return { data: rows, pagination: { next_cursor: next == null || next === "" ? null : String(next) } } as unknown as PaginatedResponse<T>;
33
+ }
34
+
35
+ export const when = (v: unknown): string => {
36
+ const d = new Date(str(v));
37
+ return Number.isNaN(d.getTime()) ? "" : d.toLocaleString();
38
+ };
39
+
40
+ /** `YYYY-MM-DD` from a date input to the UTC instant the API takes: start of that day, or the start of the NEXT day for an inclusive end. */
41
+ export function dayInstant(day: string, endOfDay = false): string | undefined {
42
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return undefined;
43
+ const d = new Date(`${day}T00:00:00Z`);
44
+ if (Number.isNaN(d.getTime())) return undefined;
45
+ if (endOfDay) d.setUTCDate(d.getUTCDate() + 1);
46
+ return d.toISOString();
47
+ }
48
+
49
+ /** A member of a form group, under whichever key names it used. */
50
+ export function memberRef(m: Rec): { form_key: string; source_id: string } {
51
+ return { form_key: str(m.form_key), source_id: str(m.source_id) || str(m.source_key) };
52
+ }
53
+
54
+ /** One CSV cell: quoted when it holds a comma, quote or newline, and guarded against spreadsheet formulas. */
55
+ export function csvCell(value: unknown): string {
56
+ let s = value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
57
+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
58
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
59
+ }
60
+
61
+ /** The CSV text for submissions: a fixed leading set of columns, then every submitted field seen, in first-seen order. */
62
+ export function submissionsCsv(rows: Array<{ id: string; received_at?: string; is_test?: boolean; delivery_state: string; form_data?: Record<string, unknown> }>): string {
63
+ const fields = [...new Set(rows.flatMap((r) => Object.keys(r.form_data ?? {})))];
64
+ const head = ["submission_id", "received_at", "test", "delivery", ...fields];
65
+ const lines = rows.map((r) => [r.id, r.received_at ?? "", r.is_test ? "yes" : "no", r.delivery_state, ...fields.map((f) => r.form_data?.[f])].map(csvCell).join(","));
66
+ return [head.map(csvCell).join(","), ...lines].join("\r\n");
67
+ }
@@ -94,3 +94,8 @@ IDs passed between steps: `thread.id` + `thread.channel` (list -> view -> draft/
94
94
  - `01-triage-and-reply.ts`: list, read, AI draft (billable), send (external).
95
95
  - `02-tag-and-assign.ts`: create a tag, apply it, assign a teammate.
96
96
  - `03-hot-contacts-digest.ts`: read-only digest of engaging contacts and unowned threads.
97
+
98
+ ## Cross-block glue
99
+
100
+
101
+ - `RecordConversationsTab` (`components/record-conversations-tab.tsx`): the email conversations with a contact. The inbox has no filter by contact id, so it matches the contact's email through `searchInboxEmails` (`search_emails`) across the mailboxes from `listInboxMailboxes`, 10 per page with "Load more"; opening a row reads `getReply`, and the API's uniform 404 for a hidden thread shows "not available to you" (no retry). `extraDetailTabs` entry for ContactsScreen.