@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,211 @@
1
+ "use client";
2
+
3
+ /**
4
+ * NewsletterSettings: everything about the newsletter itself. Its name and goal, who it sends as, double opt-in and
5
+ * address-check policy, the sending domain (start verification, read the DNS records, check status), and whether it is
6
+ * running (resume a paused newsletter, mark it completed).
7
+ *
8
+ * SDK operations, in the order the screen uses them:
9
+ * 1. getNewsletter GET /newsletters/{id} (read)
10
+ * 2. updateNewsletter PATCH /newsletters/{id} { name?, goal? } (write; ONLY changed fields are sent)
11
+ * 3. getNewsletterSettings GET /newsletters/{id}/settings (read)
12
+ * 4. updateNewsletterSettings PUT /newsletters/{id}/settings (write; ONLY changed fields are sent)
13
+ * 5. verifyNewsletterDomain POST /newsletters/{id}/settings/verify-domain { domain } (EXTERNAL, confirmed: registers the domain with the email provider)
14
+ * 6. newsletterDomainStatus GET /newsletters/{id}/settings/domain-status?domain (read; may mark the domain verified)
15
+ * 7. resumeNewsletter POST /newsletters/{id}/resume (EXTERNAL, confirmed: scheduled issues can send again)
16
+ * 8. completeNewsletter POST /newsletters/{id}/complete (write; two-step button, can be re-activated)
17
+ *
18
+ * Each form is seeded from the loaded record only and stays read-only until it has loaded, so a blank form can never be
19
+ * saved over real values. Pausing and activating stay on the screen header.
20
+ */
21
+ import { useState } from "react";
22
+ import { Button, Input, Label, Skeleton, Switch } from "@graph8/sdk/ui";
23
+ import { ApiError } from "@/g8/components/api-error";
24
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
25
+ import { useConfirmAction } from "@/g8/components/confirmed-action";
26
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
27
+ import { SectionCard } from "@/g8/components/screen/section-card";
28
+ import { StatusPill } from "@/g8/components/screen/status-pill";
29
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
30
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
31
+ import { g8Get, g8Patch, g8Post, g8Put } from "@/g8/lib/g8-client";
32
+ import type { Newsletter } from "../lib/newsletter-types";
33
+ import { asRec, changedFields, dnsRecordsOf, isDomain, str } from "../lib/newsletter-extra";
34
+ import type { Rec } from "../lib/newsletter-extra";
35
+
36
+ export interface NewsletterSettingsProps {
37
+ newsletterId: string;
38
+ /** Called after the newsletter or its status changed, so the screen refreshes its header. */
39
+ onChanged?: () => void;
40
+ className?: string;
41
+ }
42
+
43
+ function Basics({ newsletter, onSaved }: { newsletter: Newsletter; onSaved: () => void }) {
44
+ // Only what the person typed is held as `edits`, shown over the saved copy: a reload never wipes typing.
45
+ const saved = { name: newsletter.name ?? "", goal: newsletter.goal ?? "" };
46
+ const [edits, setEdits] = useState<Partial<typeof saved>>({});
47
+ const draft = { ...saved, ...edits };
48
+ const setDraft = (next: typeof saved): void => setEdits(Object.fromEntries(Object.entries(next).filter(([k, v]) => v !== saved[k as keyof typeof saved])) as Partial<typeof saved>);
49
+ const changes = changedFields(saved, draft);
50
+ const save = useG8Mutation(() => g8Patch(`/newsletters/${encodeURIComponent(newsletter.id)}`, changes), { onSuccess: () => { setEdits({}); onSaved(); } });
51
+ const bad = "name" in changes && String(changes.name).trim() === "";
52
+ return (
53
+ <form className="grid gap-3 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (Object.keys(changes).length && !bad) void save.mutate(undefined); }}>
54
+ <div><Label htmlFor="ns-name" className="mb-1">Name</Label><Input id="ns-name" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} aria-invalid={bad} /></div>
55
+ <div><Label htmlFor="ns-goal" className="mb-1">Goal</Label><Input id="ns-goal" value={draft.goal} placeholder="What this newsletter is for" onChange={(e) => setDraft({ ...draft, goal: e.target.value })} /></div>
56
+ <div className="sm:col-span-2"><Button type="submit" loading={save.loading} disabled={Object.keys(changes).length === 0 || bad}>Save name and goal</Button></div>
57
+ {save.error ? <div className="sm:col-span-2"><ApiError error={save.error} title="Could not save" /></div> : null}
58
+ </form>
59
+ );
60
+ }
61
+
62
+ function SenderForm({ saved, newsletterId, onSaved }: { saved: Rec; newsletterId: string; onSaved: () => void }) {
63
+ const initial = {
64
+ default_sender_name: str(saved.default_sender_name),
65
+ default_sender_email: str(saved.default_sender_email),
66
+ default_reply_to: str(saved.default_reply_to),
67
+ double_opt_in_intent: Boolean(saved.double_opt_in_intent ?? saved.double_opt_in),
68
+ skip_unverified_subscribers: Boolean(saved.skip_unverified_subscribers),
69
+ auto_verify_on_import: Boolean(saved.auto_verify_on_import),
70
+ };
71
+ const [edits, setEdits] = useState<Partial<typeof initial>>({});
72
+ const draft = { ...initial, ...edits };
73
+ const setDraft = (next: typeof initial): void => setEdits(Object.fromEntries(Object.entries(next).filter(([k, v]) => v !== initial[k as keyof typeof initial])) as Partial<typeof initial>);
74
+ const changes = changedFields(initial, draft);
75
+ const emailBad = ["default_sender_email", "default_reply_to"].some((k) => draft[k as keyof typeof draft] !== "" && !/^\S+@\S+\.\S+$/.test(String(draft[k as keyof typeof draft])));
76
+ const save = useG8Mutation(() => g8Put(`/newsletters/${encodeURIComponent(newsletterId)}/settings`, Object.fromEntries(Object.entries(changes).map(([k, v]) => [k, v === "" ? null : v]))), { onSuccess: () => { setEdits({}); onSaved(); } });
77
+ const toggle = (key: "double_opt_in_intent" | "skip_unverified_subscribers" | "auto_verify_on_import", label: string, help: string) => (
78
+ <div className="flex items-start gap-2">
79
+ <Switch id={`ns-${key}`} checked={draft[key]} onCheckedChange={(v) => setDraft({ ...draft, [key]: v })} aria-label={label} />
80
+ <div><Label htmlFor={`ns-${key}`}>{label}</Label><p className="text-muted-foreground text-xs">{help}</p></div>
81
+ </div>
82
+ );
83
+ return (
84
+ <form className="grid gap-3 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (Object.keys(changes).length && !emailBad) void save.mutate(undefined); }}>
85
+ <div><Label htmlFor="ns-sname" className="mb-1">Sender name</Label><Input id="ns-sname" value={draft.default_sender_name} onChange={(e) => setDraft({ ...draft, default_sender_name: e.target.value })} /></div>
86
+ <div><Label htmlFor="ns-semail" className="mb-1">Sender email</Label><Input id="ns-semail" type="email" value={draft.default_sender_email} onChange={(e) => setDraft({ ...draft, default_sender_email: e.target.value })} /></div>
87
+ <div><Label htmlFor="ns-reply" className="mb-1">Reply to</Label><Input id="ns-reply" type="email" value={draft.default_reply_to} onChange={(e) => setDraft({ ...draft, default_reply_to: e.target.value })} /></div>
88
+ <div className="grid gap-3 sm:col-span-2">
89
+ {toggle("double_opt_in_intent", "Ask new subscribers to confirm (double opt-in)", "Turns on once the sending domain is verified and a sender email is set.")}
90
+ {toggle("auto_verify_on_import", "Check addresses when a list is imported", "Verifying addresses uses credits.")}
91
+ {toggle("skip_unverified_subscribers", "Skip addresses that failed the check", "They stay subscribed but are not mailed.")}
92
+ </div>
93
+ {emailBad ? <p role="alert" className="text-error-foreground text-xs sm:col-span-2">Enter a full email address, for example news@updates.acme.com.</p> : null}
94
+ <div className="sm:col-span-2"><Button type="submit" loading={save.loading} disabled={Object.keys(changes).length === 0 || emailBad}>Save sender settings</Button></div>
95
+ {save.error ? <div className="sm:col-span-2"><ApiError error={save.error} title="Could not save the settings" /></div> : null}
96
+ </form>
97
+ );
98
+ }
99
+
100
+ function SendingDomain({ saved, newsletterId, onChanged }: { saved: Rec; newsletterId: string; onChanged: () => void }) {
101
+ const { ask, dialog, failure } = useConfirmAction();
102
+ const base = `/newsletters/${encodeURIComponent(newsletterId)}/settings`;
103
+ const [domain, setDomain] = useState(str(saved.sending_domain));
104
+ const [records, setRecords] = useState<Rec[]>([]);
105
+ const [note, setNote] = useState("");
106
+ const verified = saved.domain_verified === true;
107
+ const status = useG8Mutation(async () => {
108
+ const d = asRec((await g8Get(`${base}/domain-status`, { domain: domain.trim() })).data);
109
+ setNote(d.verified === true ? "Verified. You can send from this domain." : "Not verified yet. DNS changes can take a while to spread; check again later.");
110
+ if (d.verified === true) onChanged();
111
+ });
112
+ const start = (): void => ask({
113
+ tier: "external",
114
+ title: `Start verifying ${domain.trim()}`,
115
+ confirmLabel: "Start verification",
116
+ description: "Registers this domain with the email provider and saves it as the sending domain. You then add the DNS records it returns at your DNS host. Nothing is sent.",
117
+ run: async () => {
118
+ const d = asRec((await g8Post(`${base}/verify-domain`, { domain: domain.trim() })).data);
119
+ setRecords(dnsRecordsOf(d));
120
+ setNote(str(d.instructions));
121
+ },
122
+ onDone: onChanged,
123
+ });
124
+ return (
125
+ <div className="grid gap-3">
126
+ <div className="flex flex-wrap items-end gap-2">
127
+ <div className="min-w-60 flex-1"><Label htmlFor="ns-domain" className="mb-1">Sending domain</Label><Input id="ns-domain" placeholder="updates.acme.com" value={domain} onChange={(e) => setDomain(e.target.value)} aria-invalid={domain !== "" && !isDomain(domain)} /></div>
128
+ <Button variant="outline" disabled={!isDomain(domain)} onClick={start}>Start verification</Button>
129
+ <Button variant="outline" loading={status.loading} disabled={!isDomain(domain)} onClick={() => void status.mutate(undefined)}>Check status</Button>
130
+ {verified ? <StatusPill tone="positive">Verified</StatusPill> : saved.sending_domain ? <StatusPill tone="warning">Not verified</StatusPill> : null}
131
+ </div>
132
+ {domain !== "" && !isDomain(domain) ? <p role="alert" className="text-error-foreground text-xs">Enter a domain such as updates.acme.com, without https://.</p> : null}
133
+ {note ? <p role="status" className="text-sm">{note}</p> : null}
134
+ {records.length > 0 ? (
135
+ <ul className="grid gap-2" aria-label="DNS records to add">
136
+ {records.map((r, i) => (
137
+ <li key={i} className="rounded-md border p-2 text-xs"><span className="font-medium">{str(r.type) || str(r.record_type)}</span> <span className="font-mono">{str(r.name)}</span> <span className="font-mono break-all">{str(r.value)}</span></li>
138
+ ))}
139
+ </ul>
140
+ ) : null}
141
+ {status.error ? <ApiError error={status.error} title="Could not check the domain" /> : null}
142
+ {failure ? <ApiError error={failure} title="Could not start verification" /> : null}
143
+ <ConfirmSpendDialog {...dialog} />
144
+ </div>
145
+ );
146
+ }
147
+
148
+ function Lifecycle({ newsletter, onChanged }: { newsletter: Newsletter; onChanged: () => void }) {
149
+ const { ask, dialog, failure } = useConfirmAction();
150
+ const [sure, setSure] = useState(false);
151
+ const base = `/newsletters/${encodeURIComponent(newsletter.id)}`;
152
+ const complete = useG8Mutation(() => g8Post(`${base}/complete`), { onSuccess: () => { setSure(false); onChanged(); } });
153
+ const status = newsletter.status ?? "draft";
154
+ return (
155
+ <div className="grid gap-3">
156
+ <p className="text-sm">Status: <StatusPill tone={status === "active" ? "positive" : "neutral"}>{status}</StatusPill></p>
157
+ <div className="flex flex-wrap gap-2">
158
+ {status === "paused" ? (
159
+ <Button variant="outline" onClick={() => ask({
160
+ tier: "external", title: `Resume ${newsletter.name}`, confirmLabel: "Resume newsletter",
161
+ description: "Issues scheduled for a time that has passed send to real subscribers again, and cannot be recalled.",
162
+ run: () => g8Post(`${base}/resume`), onDone: onChanged,
163
+ })}>Resume newsletter</Button>
164
+ ) : null}
165
+ {status !== "completed" ? (
166
+ sure ? (
167
+ <>
168
+ <Button variant="outline" loading={complete.loading} onClick={() => void complete.mutate(undefined)}>Yes, stop this newsletter</Button>
169
+ <Button variant="ghost" onClick={() => setSure(false)}>Keep it running</Button>
170
+ </>
171
+ ) : <Button variant="outline" onClick={() => setSure(true)}>Mark as completed</Button>
172
+ ) : <p className="text-muted-foreground text-sm">Completed newsletters send nothing. Activate it from the header to start again.</p>}
173
+ </div>
174
+ {sure ? <p className="text-muted-foreground text-xs">No more issues are sent. You can activate the newsletter again later.</p> : null}
175
+ {complete.error ? <ApiError error={complete.error} title="Could not complete the newsletter" /> : null}
176
+ {failure ? <ApiError error={failure} title="That did not work" /> : null}
177
+ <ConfirmSpendDialog {...dialog} />
178
+ </div>
179
+ );
180
+ }
181
+
182
+ export function NewsletterSettings({ newsletterId, onChanged, className }: NewsletterSettingsProps) {
183
+ const base = `/newsletters/${encodeURIComponent(newsletterId)}`;
184
+ const nl = useG8Query((signal) => g8Get<Newsletter>(base, undefined, { signal }).then((r) => r.data), [newsletterId]);
185
+ const settings = useG8Query((signal) => g8Get(`${base}/settings`, undefined, { signal }), [newsletterId]);
186
+ const saved = asRec(settings.data?.data);
187
+ const changed = (): void => { void nl.refetch(); void settings.refetch(); onChanged?.(); };
188
+ return (
189
+ <div className={className}>
190
+ <div className="grid gap-4">
191
+ <SectionCard title="Name and goal">
192
+ {nl.error ? <ApiError error={nl.error} title="Could not load the newsletter" onRetry={() => void nl.refetch()} /> : !nl.data ? <Skeleton className="h-20 w-full" aria-busy="true" /> : (
193
+ <Basics key={nl.data.id} newsletter={nl.data} onSaved={changed} />
194
+ )}
195
+ </SectionCard>
196
+ <SectionCard title="Sender and opt-in" description="Who issues come from, and how people confirm.">
197
+ {settings.error ? <ApiError error={settings.error} title="Could not load the settings" onRetry={() => void settings.refetch()} /> : !settings.data ? <Skeleton className="h-32 w-full" aria-busy="true" /> : (
198
+ <SenderForm key={newsletterId} saved={saved} newsletterId={newsletterId} onSaved={changed} />
199
+ )}
200
+ </SectionCard>
201
+ <SectionCard title="Sending domain" description="Issues are only delivered from a verified domain.">
202
+ {settings.data ? <SendingDomain key={newsletterId} saved={saved} newsletterId={newsletterId} onChanged={changed} /> : <Skeleton className="h-16 w-full" aria-busy="true" />}
203
+ </SectionCard>
204
+ <SectionCard title="Running state">
205
+ {nl.data ? <Lifecycle newsletter={nl.data} onChanged={changed} /> : <Skeleton className="h-12 w-full" aria-busy="true" />}
206
+ </SectionCard>
207
+ {nl.data ? <KeyValueList columns={2} items={[{ label: "Newsletter id", value: nl.data.id, mono: true }, { label: "Slug", value: nl.data.slug }]} /> : null}
208
+ </div>
209
+ </div>
210
+ );
211
+ }
@@ -30,7 +30,7 @@ function Tile({ label, value, sub }: { label: string; value: number | undefined;
30
30
  return (
31
31
  <div className="rounded-md border p-3">
32
32
  <p className="text-muted-foreground text-xs">{label}</p>
33
- <p className="text-xl font-semibold">{(value ?? 0).toLocaleString()}</p>
33
+ <p className="text-xl font-medium">{(value ?? 0).toLocaleString()}</p>
34
34
  {sub ? <p className="text-muted-foreground text-xs">{sub}</p> : null}
35
35
  </div>
36
36
  );
@@ -69,7 +69,7 @@ export function NewsletterStats({ newsletterId }: NewsletterStatsProps) {
69
69
  <CardHeader><CardTitle>Issue details</CardTitle></CardHeader>
70
70
  <CardContent className="grid gap-3">
71
71
  {detail.error ? <ApiError error={detail.error} title="Could not load the numbers" onRetry={() => void detail.refetch()} /> : detail.loading && !detail.data ? <Skeleton className="h-20 w-full" /> : detail.data?.stale ? (
72
- <p role="alert" className="text-sm text-amber-700">The numbers could not be read just now. This is not zero engagement: try again in a minute.</p>
72
+ <p role="alert" className="text-sm text-warning-foreground">The numbers could not be read just now. This is not zero engagement: try again in a minute.</p>
73
73
  ) : (
74
74
  <>
75
75
  <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ /**
4
+ * NewsletterTemplates: your saved issue templates. Start a new draft issue from one, approve or archive it, rename it,
5
+ * delete it, or have AI write a new one from an idea.
6
+ *
7
+ * SDK operations, in the order the screen uses them:
8
+ * 1. listTemplates (newsletter) GET /newsletters/templates (read; the whole list, no cap)
9
+ * 2. useTemplate POST /newsletters/templates/{id}/use { newsletter_id } (write; creates a DRAFT issue, sends nothing)
10
+ * 3. setTemplateApproval POST /newsletters/templates/{id}/approval { status } (write)
11
+ * 4. updateNewsletterTemplate PATCH /newsletters/templates/{id} { name } (write; only a changed name is sent)
12
+ * 5. deleteNewsletterTemplate DELETE /newsletters/templates/{id} (destructive, confirmed)
13
+ * 6. createTemplateFromPrompt POST /newsletters/templates/custom { idea, tone?, length? } (BILLABLE, confirmed; builds in the background)
14
+ * 7. templateGenerationStatus GET /newsletters/templates/{id}/generation-status (read; polled every 3 s while a template is being written)
15
+ *
16
+ * The API quotes no price for AI templates in advance and the balance is not checked here, so the dialog says what runs.
17
+ */
18
+ import { useEffect, useState } from "react";
19
+ import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
20
+ import { ApiError } from "@/g8/components/api-error";
21
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
22
+ import { useConfirmAction } from "@/g8/components/confirmed-action";
23
+ import { EmptyState } from "@/g8/components/empty-state";
24
+ import { SectionCard } from "@/g8/components/screen/section-card";
25
+ import { StatusPill } from "@/g8/components/screen/status-pill";
26
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
27
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
28
+ import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
29
+ import { asRec, num, rowsOf, str } from "../lib/newsletter-extra";
30
+ import type { Rec } from "../lib/newsletter-extra";
31
+
32
+ export interface NewsletterTemplatesProps {
33
+ /** The newsletter a template is used in. */
34
+ newsletterId: string;
35
+ /** Called with the new draft issue's id after "Use for a new issue". */
36
+ onUsed?: (issueId: number) => void;
37
+ className?: string;
38
+ }
39
+
40
+ const SELECT = "border-input bg-background h-8 rounded-md border px-2 text-sm";
41
+ const APPROVAL = ["draft", "approved", "archived"] as const;
42
+ const WORKING = /generating|pending|queued|running/i;
43
+
44
+ /** The API stores "archived" as "rejected" and returns the raw value: show it as Archived. */
45
+ export function approvalOf(t: Rec): string {
46
+ const v = str(t.approval_status, "draft");
47
+ return v === "rejected" ? "archived" : v;
48
+ }
49
+
50
+ function TemplateRow({ t, newsletterId, onChanged, onUsed }: { t: Rec; newsletterId: string; onChanged: () => void; onUsed?: (id: number) => void }) {
51
+ const { ask, dialog, failure } = useConfirmAction();
52
+ const id = num(t.id) as number;
53
+ const base = `/newsletters/templates/${id}`;
54
+ const [name, setName] = useState(str(t.name));
55
+ const generating = WORKING.test(str(t.generation_status));
56
+ const rename = useG8Mutation(() => g8Patch(base, { name: name.trim() }), { onSuccess: onChanged });
57
+ const approve = useG8Mutation((status: string) => g8Post(`${base}/approval`, { status }), { onSuccess: onChanged });
58
+ const use = useG8Mutation(() => g8Post(`${base}/use`, { newsletter_id: newsletterId }), { onSuccess: (res) => { const n = num(asRec(res.data).id); if (n !== undefined) onUsed?.(n); } });
59
+ const status = useG8Query((signal) => g8Get(`${base}/generation-status`, undefined, { signal }), [id, str(t.generation_status)], { enabled: generating });
60
+ useEffect(() => {
61
+ if (!generating) return;
62
+ const state = str(asRec(status.data?.data).status);
63
+ if (status.data && state !== "" && !WORKING.test(state)) { onChanged(); return; }
64
+ // A failed poll is retried too (a blip must not stop the "Writing" state for good).
65
+ const timer = setTimeout(() => void status.refetch(), 3000);
66
+ return () => clearTimeout(timer);
67
+ }, [generating, status.data, status.error]); // eslint-disable-line react-hooks/exhaustive-deps
68
+ const dirty = name.trim() !== "" && name.trim() !== str(t.name);
69
+ return (
70
+ <li className="grid gap-2 rounded-md border p-3">
71
+ <div className="flex flex-wrap items-center gap-2">
72
+ <Input aria-label={`Template name ${str(t.name)}`} className="max-w-xs" value={name} onChange={(e) => setName(e.target.value)} />
73
+ <Button size="sm" variant="outline" loading={rename.loading} disabled={!dirty} onClick={() => void rename.mutate(undefined)}>Rename</Button>
74
+ {generating ? <StatusPill tone="info">Writing</StatusPill> : null}
75
+ <select aria-label={`Approval of ${str(t.name)}`} className={SELECT} value={approvalOf(t)} disabled={approve.loading} onChange={(e) => void approve.mutate(e.target.value)}>
76
+ {APPROVAL.map((a) => <option key={a} value={a}>{a[0].toUpperCase() + a.slice(1)}</option>)}
77
+ </select>
78
+ <div className="ml-auto flex gap-1">
79
+ <Button size="sm" loading={use.loading} disabled={generating} aria-label={`Use ${str(t.name)} for a new issue`} onClick={() => void use.mutate(undefined)}>Use for a new issue</Button>
80
+ <Button size="sm" variant="ghost" aria-label={`Delete ${str(t.name)}`} onClick={() => ask({
81
+ tier: "destructive", title: `Delete the template "${str(t.name)}"`, confirmLabel: "Delete template", affected: 1,
82
+ description: "The template is removed. Issues already made from it are not changed.", tolerateStatus: [404],
83
+ run: () => g8Delete(base), onDone: onChanged,
84
+ })}>Delete</Button>
85
+ </div>
86
+ </div>
87
+ {str(t.subject) ? <p className="text-muted-foreground text-xs">Subject: {str(t.subject)}</p> : null}
88
+ {[rename.error, approve.error, use.error].filter(Boolean).map((e, i) => <ApiError key={i} error={e} title="That did not work" />)}
89
+ {failure ? <ApiError error={failure} title="Could not delete the template" /> : null}
90
+ <ConfirmSpendDialog {...dialog} />
91
+ </li>
92
+ );
93
+ }
94
+
95
+ export function NewsletterTemplates({ newsletterId, onUsed, className }: NewsletterTemplatesProps) {
96
+ const { ask, dialog, failure } = useConfirmAction();
97
+ const list = useG8Query((signal) => g8Get("/newsletters/templates", undefined, { signal }), []);
98
+ const rows = rowsOf<Rec>(list.data?.data, "templates").filter((t) => num(t.id) !== undefined);
99
+ const [idea, setIdea] = useState("");
100
+ const [tone, setTone] = useState("");
101
+ const [length, setLength] = useState("");
102
+ const [note, setNote] = useState("");
103
+ const create = (): void => ask({
104
+ tier: "billable", title: "Write a template with AI", confirmLabel: "Write template",
105
+ description: "AI writes the copy for a new template from your idea. Uses credits; the API gives no price in advance and your balance is not checked here. It appears below while it is being written.",
106
+ run: async () => { await g8Post("/newsletters/templates/custom", { idea: idea.trim(), tone: tone.trim() || undefined, length: length.trim() || undefined }); setNote("The template is being written. It will finish by itself."); setIdea(""); },
107
+ onDone: () => void list.refetch(),
108
+ });
109
+ return (
110
+ <div className={className}>
111
+ <div className="grid gap-4">
112
+ <SectionCard title="Templates" description="Start a new draft issue from a saved layout.">
113
+ {list.error ? <ApiError error={list.error} title="Could not load templates" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? <Skeleton className="h-24 w-full" aria-busy="true" /> : rows.length === 0 ? (
114
+ <EmptyState compact title="No templates yet" description="Write one with AI below, or save an issue's layout in the web app." />
115
+ ) : (
116
+ <ul className="grid gap-2">{rows.map((t) => <TemplateRow key={String(t.id)} t={t} newsletterId={newsletterId} onChanged={() => void list.refetch()} onUsed={onUsed} />)}</ul>
117
+ )}
118
+ </SectionCard>
119
+ <SectionCard title="Write a template with AI" description="Describe the newsletter you want.">
120
+ <form className="grid gap-3 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (idea.trim()) create(); }}>
121
+ <div className="sm:col-span-2"><Label htmlFor="nt-idea" className="mb-1">Idea</Label><Textarea id="nt-idea" rows={3} value={idea} onChange={(e) => setIdea(e.target.value)} placeholder="A monthly product digest with one big story and three quick links" /></div>
122
+ <div><Label htmlFor="nt-tone" className="mb-1">Tone (optional)</Label><Input id="nt-tone" value={tone} placeholder="Warm and direct" onChange={(e) => setTone(e.target.value)} /></div>
123
+ <div><Label htmlFor="nt-length" className="mb-1">Length (optional)</Label><Input id="nt-length" value={length} placeholder="Short" onChange={(e) => setLength(e.target.value)} /></div>
124
+ <div className="sm:col-span-2"><Button type="submit" disabled={!idea.trim()}>Write template with AI</Button></div>
125
+ </form>
126
+ {note ? <p role="status" className="mt-2 text-sm">{note}</p> : null}
127
+ </SectionCard>
128
+ {failure ? <ApiError error={failure} title="The template was not written" /> : null}
129
+ </div>
130
+ <ConfirmSpendDialog {...dialog} />
131
+ </div>
132
+ );
133
+ }
@@ -17,6 +17,8 @@
17
17
  import { useState } from "react";
18
18
  import { Button, Input, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
19
19
  import { ApiError } from "@/g8/components/api-error";
20
+ import { useAllLists } from "@/g8/crm/hooks/use-all-lists";
21
+ import { ListSelect } from "@/g8/crm/components/list-select";
20
22
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
23
  import { EmptyState } from "@/g8/components/empty-state";
22
24
  import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
@@ -44,7 +46,6 @@ type Action =
44
46
  | { kind: "delete"; subscriber: Subscriber };
45
47
 
46
48
  interface Page { items: Subscriber[]; total: number }
47
- interface ListOption { id: number; title: string; contact_count?: number }
48
49
 
49
50
  function creditsOf(preview: unknown): number | undefined {
50
51
  const p = (preview ?? {}) as Record<string, unknown>;
@@ -83,7 +84,7 @@ function SubscribersOf({ newsletterId, pageSize, onOpen, selectedId, refreshKey
83
84
  (signal) => g8Get<Page>(base, { page, page_size: pageSize, search: query || undefined, status: status || undefined }, { signal }).then((r) => ({ items: r.data?.items ?? [], total: r.data?.total ?? 0 })),
84
85
  [base, page, pageSize, query, status, refreshKey],
85
86
  );
86
- const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }).then((r) => r.data ?? []), []);
87
+ const lists = useAllLists([]);
87
88
  const preview = useG8Mutation(async () => (await g8Post(`${base}/verify/preview`, {})).data);
88
89
 
89
90
  const run = useG8Mutation(async (a: Action) => {
@@ -116,11 +117,8 @@ function SubscribersOf({ newsletterId, pageSize, onOpen, selectedId, refreshKey
116
117
  <Button type="submit" disabled={!draft.email.includes("@")}>Add subscriber</Button>
117
118
  </form>
118
119
  <div className="flex flex-wrap items-end gap-2">
119
- <select aria-label="Import from list" value={listId} onChange={(e) => setListId(e.target.value)} className="border-input bg-background h-9 rounded-md border px-2 text-sm">
120
- <option value="">Import from a list...</option>
121
- {(lists.data ?? []).map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
122
- </select>
123
- <Button variant="outline" disabled={!listId} onClick={() => setAction({ kind: "import", listId: Number(listId), contacts: lists.data?.find((l) => l.id === Number(listId))?.contact_count })}>Import list</Button>
120
+ <ListSelect label="Import from list" emptyOption="Import from a list..." pageSize={200} value={listId} onChange={(l) => setListId(l ? String(l.id) : "")} />
121
+ <Button variant="outline" disabled={!listId} onClick={() => setAction({ kind: "import", listId: Number(listId), contacts: lists.items.find((l) => l.id === Number(listId))?.contact_count ?? undefined })}>Import list</Button>
124
122
  <Button variant="outline" loading={preview.loading} onClick={() => void preview.mutate(undefined)}>Check verification cost</Button>
125
123
  {preview.data ? <Button variant="outline" onClick={() => setAction({ kind: "verify" })}>Verify addresses</Button> : null}
126
124
  </div>
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SubscriberTimeline: what one subscriber did with one sent issue (delivered, opened, clicked...), in order.
5
+ * Pick the issue from the ones already sent.
6
+ *
7
+ * SDK operations:
8
+ * 1. getSubscriberTimeline GET /newsletters/{id}/issues/{issue}/analytics/subscribers/{subscriber}/timeline (read)
9
+ *
10
+ * The payload is open-shaped: events are read from `events` (or `timeline`, or a bare list) and shown with whatever
11
+ * of `type`, `event`, `occurred_at`, `url` is present.
12
+ */
13
+ import { useEffect, useState } from "react";
14
+ import { Skeleton } from "@graph8/sdk/ui";
15
+ import { ApiError } from "@/g8/components/api-error";
16
+ import { EmptyState } from "@/g8/components/empty-state";
17
+ import { SectionCard } from "@/g8/components/screen/section-card";
18
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
19
+ import { g8Get } from "@/g8/lib/g8-client";
20
+ import { issueTitle } from "../lib/newsletter-types";
21
+ import type { Issue } from "../lib/newsletter-types";
22
+ import { rowsOf, str } from "../lib/newsletter-extra";
23
+ import type { Rec } from "../lib/newsletter-extra";
24
+
25
+ export interface SubscriberTimelineProps {
26
+ newsletterId: string;
27
+ subscriberId: number;
28
+ /** The issues to choose from (only sent ones are offered). */
29
+ issues: Issue[];
30
+ className?: string;
31
+ }
32
+
33
+ const when = (v: unknown): string => {
34
+ const d = new Date(str(v));
35
+ return Number.isNaN(d.getTime()) ? "" : d.toLocaleString();
36
+ };
37
+
38
+ export function SubscriberTimeline({ newsletterId, subscriberId, issues, className }: SubscriberTimelineProps) {
39
+ const sent = issues.filter((i) => i.status === "sent" || i.sent_at);
40
+ const [issueId, setIssueId] = useState<string>(sent[0] ? String(sent[0].id) : "");
41
+ // On a deep link the issues arrive after the first render: pick the newest sent one once they do.
42
+ useEffect(() => { if (issueId === "" && sent[0]) setIssueId(String(sent[0].id)); }, [issueId, sent.length]); // eslint-disable-line react-hooks/exhaustive-deps
43
+ const res = useG8Query(
44
+ (signal) => g8Get(`/newsletters/${encodeURIComponent(newsletterId)}/issues/${issueId}/analytics/subscribers/${subscriberId}/timeline`, undefined, { signal }),
45
+ [newsletterId, issueId, subscriberId],
46
+ { enabled: issueId !== "" },
47
+ );
48
+ const events = rowsOf<Rec>(res.data?.data, "events", "timeline");
49
+ return (
50
+ <SectionCard className={className} title="Activity on an issue" description="What this person did with one sent issue."
51
+ actions={sent.length ? <select aria-label="Issue" className="border-input bg-background h-8 rounded-md border px-2 text-sm" value={issueId} onChange={(e) => setIssueId(e.target.value)}>{sent.map((i) => <option key={i.id} value={String(i.id)}>{issueTitle(i)}</option>)}</select> : undefined}>
52
+ {sent.length === 0 ? <EmptyState compact title="No sent issues yet" description="Activity appears after an issue is sent." />
53
+ : res.error ? <ApiError error={res.error} title="Could not load the activity" onRetry={() => void res.refetch()} />
54
+ : res.loading && !res.data ? <Skeleton className="h-16 w-full" aria-busy="true" />
55
+ : events.length === 0 ? <EmptyState compact title="No activity recorded" description="This person has not opened or clicked this issue." />
56
+ : (
57
+ <ol className="grid gap-2">
58
+ {events.map((e, i) => (
59
+ <li key={i} className="rounded-md border p-2 text-sm"><span className="font-medium">{str(e.type) || str(e.event) || "Event"}</span> <span className="text-muted-foreground text-xs">{when(e.occurred_at) || when(e.timestamp) || when(e.created_at)}</span>{str(e.url) ? <span className="text-muted-foreground block truncate text-xs">{str(e.url)}</span> : null}</li>
60
+ ))}
61
+ </ol>
62
+ )}
63
+ </SectionCard>
64
+ );
65
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Small helpers for the newsletter management components (settings, versions, list syncs, templates).
5
+ * Responses for these endpoints are open dicts in the contract, so every reader accepts what is present.
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
+ /** The rows of a list payload: a bare array, or the first of `keys` (then `items`) that holds one. */
24
+ export function rowsOf<T = Rec>(data: unknown, ...keys: string[]): T[] {
25
+ if (Array.isArray(data)) return data as T[];
26
+ const rec = asRec(data);
27
+ for (const key of [...keys, "items"]) if (Array.isArray(rec[key])) return rec[key] as T[];
28
+ return [];
29
+ }
30
+
31
+ /** The fields of `draft` that differ from `saved`: the body of a partial update. Strings are compared trimmed. */
32
+ export function changedFields<T extends Record<string, string | boolean>>(saved: T, draft: T): Partial<T> {
33
+ const out: Partial<T> = {};
34
+ for (const key of Object.keys(draft) as Array<keyof T>) {
35
+ const a = saved[key];
36
+ const b = draft[key];
37
+ if (typeof b === "string" ? b.trim() !== String(a).trim() : b !== a) out[key] = (typeof b === "string" ? b.trim() : b) as T[keyof T];
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** True for a bare hostname such as updates.acme.com (no scheme, path or spaces). */
43
+ export function isDomain(raw: string): boolean {
44
+ return /^(?=.{4,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(raw.trim());
45
+ }
46
+
47
+ /** The DNS records a verify-domain answer lists, under whichever key it used. */
48
+ export function dnsRecordsOf(data: unknown): Rec[] {
49
+ return rowsOf<Rec>(data, "dns_records", "records");
50
+ }
51
+
52
+ /** A version row's number, under whichever key it used. */
53
+ export function versionNumber(v: Rec): number | undefined {
54
+ return num(v.version_num) ?? num(v.version) ?? num(v.number) ?? num(v.id);
55
+ }
56
+
57
+ /** Wrap rows and the API's "next" marker (a cursor or an offset; null or absent when there is no more) for `usePaginated`. */
58
+ export function pageOf<T>(rows: T[], next: unknown): PaginatedResponse<T> {
59
+ return { data: rows, pagination: { next_cursor: next == null || next === "" ? null : String(next) } } as unknown as PaginatedResponse<T>;
60
+ }
@@ -17,6 +17,9 @@
17
17
  import { useState } from "react";
18
18
  import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@graph8/sdk/ui";
19
19
  import { ApiError } from "@/g8/components/api-error";
20
+ import { useLoadMoreList } from "@/g8/hooks/use-load-more-list";
21
+ import { byPage, hasNextOf } from "@/g8/lib/paging";
22
+ import { LoadMore } from "@/g8/components/paging-notices";
20
23
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
24
  import { EmptyState } from "@/g8/components/empty-state";
22
25
  import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
@@ -40,7 +43,11 @@ export function NurtureBuilder({ onSelectNurture, pageSize = 50 }: NurtureBuilde
40
43
  const [form, setForm] = useState<{ nurture?: Nurture } | null>(null);
41
44
  const [toArchive, setToArchive] = useState<Nurture | null>(null);
42
45
  const [toResume, setToResume] = useState<Nurture | null>(null);
43
- const list = useG8Query((signal) => g8Get<{ nurtures?: Nurture[] }>("/nurtures", { page: 1, limit: pageSize }, { signal }).then((r) => r.data?.nurtures ?? []), [pageSize]);
46
+ const list = useLoadMoreList(byPage<Nurture>(pageSize, async (page, signal) => {
47
+ const r = await g8Get<{ nurtures?: Nurture[] }>("/nurtures", { page, limit: pageSize }, { signal });
48
+ const rows = r.data?.nurtures ?? [];
49
+ return { rows, hasNext: hasNextOf(r, pageSize, rows.length) };
50
+ }), [pageSize]);
44
51
  const [busy, setBusy] = useState<string | null>(null);
45
52
  const act = useG8Mutation((a: Action) => g8Post(`/nurtures/${encodeURIComponent(a.nurture.id)}/${a.kind}`, a.kind === "duplicate" ? {} : undefined), { onSuccess: () => void list.refetch() });
46
53
  const run = async (a: Action): Promise<void> => {
@@ -65,7 +72,7 @@ export function NurtureBuilder({ onSelectNurture, pageSize = 50 }: NurtureBuilde
65
72
  return (
66
73
  <div className="grid gap-3">
67
74
  <div className="flex items-center justify-between">
68
- <h2 className="text-lg font-semibold">Nurtures</h2>
75
+ <h2 className="text-lg font-medium">Nurtures</h2>
69
76
  <Button onClick={() => setForm({})}>New nurture</Button>
70
77
  </div>
71
78
  {act.error ? <ApiError error={act.error} title="That did not work" /> : null}
@@ -91,6 +98,7 @@ export function NurtureBuilder({ onSelectNurture, pageSize = 50 }: NurtureBuilde
91
98
  ))}
92
99
  </ul>
93
100
  )}
101
+ <LoadMore shown={nurtures.length} noun="nurtures" hasMore={list.hasMore} loading={list.loadingMore} onLoadMore={list.loadMore} />
94
102
  <ConfirmSpendDialog
95
103
  open={toResume !== null}
96
104
  tier="external"
@@ -17,13 +17,15 @@
17
17
  import { useEffect, useState } from "react";
18
18
  import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@graph8/sdk/ui";
19
19
  import { ApiError } from "@/g8/components/api-error";
20
+ import { useAllLists } from "@/g8/crm/hooks/use-all-lists";
21
+ import { ListSelect } from "@/g8/crm/components/list-select";
20
22
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
23
  import { EmptyState } from "@/g8/components/empty-state";
22
24
  import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
23
25
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
24
26
  import { useG8Query } from "@/g8/hooks/use-g8-query";
25
27
  import { isDrafted, isNurtureMissing, isNurtureOff, statusOf } from "../lib/nurture-types";
26
- import type { ListOption, Nurture } from "../lib/nurture-types";
28
+ import type { Nurture } from "../lib/nurture-types";
27
29
  import { pluralWord } from "@/g8/lib/plural";
28
30
  import { useBrand } from "@/g8/hooks/use-brand";
29
31
 
@@ -38,7 +40,7 @@ export function NurtureEnroll({ nurtureId, contactIds = [], onLaunched }: Nurtur
38
40
  const brand = useBrand();
39
41
  const base = `/nurtures/${encodeURIComponent(nurtureId)}`;
40
42
  const nurture = useG8Query((signal) => g8Get<Nurture>(base, {}, { signal }).then((r) => r.data), [base]);
41
- const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }).then((r) => r.data ?? []), []);
43
+ const lists = useAllLists([]);
42
44
  const [listId, setListId] = useState("");
43
45
  const [consent, setConsent] = useState(false);
44
46
  const [confirming, setConfirming] = useState(false);
@@ -63,7 +65,7 @@ export function NurtureEnroll({ nurtureId, contactIds = [], onLaunched }: Nurtur
63
65
  if (nurture.error) return <ApiError error={nurture.error} title="Could not load the nurture" onRetry={() => void nurture.refetch()} />;
64
66
  if (!nurture.data) return <Skeleton className="h-40 w-full" aria-busy="true" />;
65
67
 
66
- const list = (lists.data ?? []).find((l) => String(l.id) === listId);
68
+ const list = lists.items.find((l) => String(l.id) === listId);
67
69
  const attached = nurture.data.associated_list_id ? String(nurture.data.associated_list_id) === listId : false;
68
70
  const audience = list?.contact_count ?? 0;
69
71
  const canLaunch = isDrafted(nurture.data) && attached && audience > 0 && consent;
@@ -73,10 +75,7 @@ export function NurtureEnroll({ nurtureId, contactIds = [], onLaunched }: Nurtur
73
75
  <CardContent className="grid gap-4">
74
76
  <div className="grid max-w-md gap-1">
75
77
  <label htmlFor="g8-nurture-audience" className="text-sm font-medium">Audience list</label>
76
- <select id="g8-nurture-audience" className="border-input bg-background h-9 rounded-md border px-2 text-sm" value={listId} disabled={!isDrafted(nurture.data)} onChange={(e) => setListId(e.target.value)}>
77
- <option value="">Choose a list</option>
78
- {(lists.data ?? []).map((l) => <option key={l.id} value={l.id}>{l.title} ({(l.contact_count ?? 0).toLocaleString()})</option>)}
79
- </select>
78
+ <ListSelect id="g8-nurture-audience" label="Audience list" emptyOption="Choose a list" pageSize={200} optionLabel={(l) => `${l.title} (${(l.contact_count ?? 0).toLocaleString()})`} value={listId} valueLabel={list?.title} disabled={!isDrafted(nurture.data)} onChange={(l) => setListId(l ? String(l.id) : "")} />
80
79
  <div className="flex gap-2">
81
80
  <Button size="sm" variant="outline" loading={setAudience.loading} disabled={!listId || attached || !isDrafted(nurture.data)} onClick={() => void setAudience.mutate(Number(listId))}>Use this list</Button>
82
81
  {contactIds.length > 0 ? <Button size="sm" variant="outline" loading={addContacts.loading} disabled={!attached} onClick={() => void addContacts.mutate(Number(listId))}>Add {contactIds.length} selected {pluralWord(contactIds.length, "contact")}</Button> : null}