@graph8/sdk 0.361.0 → 0.370.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/README.md +14 -10
  2. package/blocks/_foundation/README.md +172 -1
  3. package/blocks/_foundation/block.json +234 -13
  4. package/blocks/_foundation/components/api-error.tsx +71 -25
  5. package/blocks/_foundation/components/brand-provider.tsx +23 -0
  6. package/blocks/_foundation/components/confirmed-action.tsx +90 -0
  7. package/blocks/_foundation/components/connect-graph8.tsx +280 -0
  8. package/blocks/_foundation/components/empty-state.tsx +39 -7
  9. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  10. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  11. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  12. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  13. package/blocks/_foundation/components/screen/index.ts +22 -0
  14. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  15. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  16. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  17. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  18. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  19. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  20. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  21. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  22. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  23. package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
  24. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  25. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  26. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  27. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  28. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  29. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  30. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  31. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  32. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  33. package/blocks/_foundation/lib/g8-client.ts +1 -1
  34. package/blocks/_foundation/lib/plural.ts +23 -0
  35. package/blocks/_foundation/server/connect-route.ts +15 -0
  36. package/blocks/_foundation/server/connect.ts +127 -0
  37. package/blocks/_foundation/server/local-key.ts +70 -0
  38. package/blocks/_foundation/server/proxy.ts +103 -3
  39. package/blocks/_foundation/server/route.ts +16 -4
  40. package/blocks/agents/README.md +14 -0
  41. package/blocks/agents/block.json +33 -1
  42. package/blocks/agents/components/agent-builder.tsx +13 -8
  43. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
  44. package/blocks/agents/components/agent-run-detail.tsx +102 -0
  45. package/blocks/agents/components/agent-runs.tsx +16 -7
  46. package/blocks/agents/components/agents-screen.tsx +184 -0
  47. package/blocks/analytics/README.md +13 -0
  48. package/blocks/analytics/block.json +45 -1
  49. package/blocks/analytics/components/analytics-screen.tsx +209 -0
  50. package/blocks/analytics/components/leaderboard.tsx +14 -7
  51. package/blocks/analytics/components/report-builder.tsx +23 -9
  52. package/blocks/analytics/components/report-detail.tsx +84 -0
  53. package/blocks/analytics/components/trend-chart.tsx +62 -0
  54. package/blocks/appointments/README.md +21 -1
  55. package/blocks/appointments/block.json +43 -2
  56. package/blocks/appointments/components/availability-editor.tsx +17 -5
  57. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  58. package/blocks/appointments/components/event-type-related.tsx +77 -0
  59. package/blocks/appointments/components/meeting-list.tsx +14 -6
  60. package/blocks/appointments/components/meetings-screen.tsx +157 -0
  61. package/blocks/appointments/components/scheduling-screen.tsx +122 -0
  62. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  63. package/blocks/block.schema.json +18 -0
  64. package/blocks/block.types.ts +26 -0
  65. package/blocks/campaigns/README.md +41 -11
  66. package/blocks/campaigns/block.json +105 -42
  67. package/blocks/campaigns/components/campaign-assets.tsx +16 -5
  68. package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
  69. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  70. package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
  71. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  72. package/blocks/campaigns/components/icp-detail.tsx +154 -0
  73. package/blocks/campaigns/components/icp-editor.tsx +15 -15
  74. package/blocks/campaigns/components/persona-detail.tsx +172 -0
  75. package/blocks/campaigns/components/persona-editor.tsx +14 -11
  76. package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
  77. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  78. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  79. package/blocks/coach/README.md +10 -0
  80. package/blocks/coach/block.json +16 -1
  81. package/blocks/coach/components/coach-screen.tsx +222 -0
  82. package/blocks/copilot/README.md +13 -0
  83. package/blocks/copilot/block.json +14 -1
  84. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  85. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  86. package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
  87. package/blocks/crm/README.md +28 -0
  88. package/blocks/crm/block.json +58 -2
  89. package/blocks/crm/components/companies-screen.tsx +345 -0
  90. package/blocks/crm/components/company-detail.tsx +8 -6
  91. package/blocks/crm/components/company-table.tsx +11 -4
  92. package/blocks/crm/components/contact-detail.tsx +6 -3
  93. package/blocks/crm/components/contact-table.tsx +10 -3
  94. package/blocks/crm/components/contacts-screen.tsx +359 -0
  95. package/blocks/crm/components/dedupe-review.tsx +7 -5
  96. package/blocks/crm/components/import-wizard.tsx +4 -2
  97. package/blocks/crm/components/notes-panel.tsx +4 -4
  98. package/blocks/crm/components/record-form.tsx +5 -3
  99. package/blocks/crm/components/task-list.tsx +4 -4
  100. package/blocks/crm/components/tasks-screen.tsx +379 -0
  101. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  102. package/blocks/crm/hooks/use-page-query.ts +7 -1
  103. package/blocks/crm/lib/crm-types.ts +12 -2
  104. package/blocks/crm/lib/screen-parts.tsx +105 -0
  105. package/blocks/deals/README.md +34 -3
  106. package/blocks/deals/block.json +45 -4
  107. package/blocks/deals/components/deal-detail.tsx +27 -13
  108. package/blocks/deals/components/deal-related.tsx +87 -0
  109. package/blocks/deals/components/deals-screen.tsx +298 -0
  110. package/blocks/deals/components/pipeline-board.tsx +4 -2
  111. package/blocks/deals/components/stage-editor.tsx +4 -4
  112. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  113. package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
  114. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  115. package/blocks/deals/lib/deals-types.ts +14 -0
  116. package/blocks/dialer/README.md +34 -2
  117. package/blocks/dialer/block.json +67 -1
  118. package/blocks/dialer/components/call-contact-button.tsx +252 -0
  119. package/blocks/dialer/components/call-log-screen.tsx +205 -0
  120. package/blocks/dialer/components/call-log.tsx +7 -3
  121. package/blocks/dialer/components/call-queue.tsx +1 -1
  122. package/blocks/dialer/components/dialer-screen.tsx +319 -0
  123. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  124. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  125. package/blocks/dialer/components/session-start-form.tsx +4 -2
  126. package/blocks/dialer/components/softphone.tsx +3 -1
  127. package/blocks/enrichment/README.md +14 -2
  128. package/blocks/enrichment/block.json +32 -4
  129. package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
  130. package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
  131. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  132. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  133. package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
  134. package/blocks/enrichment/components/waterfall-config.tsx +39 -10
  135. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  136. package/blocks/forms/README.md +12 -0
  137. package/blocks/forms/block.json +39 -17
  138. package/blocks/forms/components/form-builder.tsx +3 -1
  139. package/blocks/forms/components/form-embed.tsx +3 -1
  140. package/blocks/forms/components/forms-screen.tsx +353 -0
  141. package/blocks/forms/components/submissions-table.tsx +16 -6
  142. package/blocks/inbox/README.md +27 -3
  143. package/blocks/inbox/block.json +14 -2
  144. package/blocks/inbox/components/inbox-list.tsx +7 -3
  145. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  146. package/blocks/inbox/components/reply-composer.tsx +65 -9
  147. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  148. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  149. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  150. package/blocks/inbox/lib/inbox-types.ts +47 -1
  151. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  152. package/blocks/integrations/README.md +9 -0
  153. package/blocks/integrations/block.json +46 -1
  154. package/blocks/integrations/components/crm-detail.tsx +100 -0
  155. package/blocks/integrations/components/integration-list.tsx +9 -4
  156. package/blocks/integrations/components/integrations-screen.tsx +135 -0
  157. package/blocks/integrations/components/sync-status.tsx +5 -3
  158. package/blocks/integrations/components/webhook-detail.tsx +160 -0
  159. package/blocks/integrations/components/webhook-manager.tsx +13 -8
  160. package/blocks/integrations/lib/integrations-utils.ts +6 -1
  161. package/blocks/intent/README.md +14 -0
  162. package/blocks/intent/block.json +27 -1
  163. package/blocks/intent/components/intent-feed.tsx +5 -2
  164. package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
  165. package/blocks/intent/components/intent-screen.tsx +265 -0
  166. package/blocks/intent/components/keyword-detail.tsx +124 -0
  167. package/blocks/lifecycle/README.md +16 -0
  168. package/blocks/lifecycle/block.json +34 -7
  169. package/blocks/lifecycle/components/account-related.tsx +66 -0
  170. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  171. package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
  172. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  173. package/blocks/lists/README.md +13 -0
  174. package/blocks/lists/block.json +39 -1
  175. package/blocks/lists/components/add-to-list-button.tsx +3 -2
  176. package/blocks/lists/components/list-picker.tsx +5 -1
  177. package/blocks/lists/components/list-table.tsx +11 -5
  178. package/blocks/lists/components/lists-screen.tsx +344 -0
  179. package/blocks/lists/components/suppression-manager.tsx +6 -2
  180. package/blocks/lists/components/suppressions-screen.tsx +255 -0
  181. package/blocks/mailboxes/README.md +17 -0
  182. package/blocks/mailboxes/block.json +65 -17
  183. package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
  184. package/blocks/mailboxes/components/domain-detail.tsx +142 -0
  185. package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
  186. package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
  187. package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
  188. package/blocks/mailboxes/components/warmup-health.tsx +4 -2
  189. package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
  190. package/blocks/newsletter/README.md +13 -0
  191. package/blocks/newsletter/block.json +47 -23
  192. package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
  193. package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
  194. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  195. package/blocks/nurture/README.md +12 -0
  196. package/blocks/nurture/block.json +35 -13
  197. package/blocks/nurture/components/nurture-builder.tsx +3 -1
  198. package/blocks/nurture/components/nurture-enroll.tsx +6 -3
  199. package/blocks/nurture/components/nurture-form.tsx +23 -3
  200. package/blocks/nurture/components/nurture-screen.tsx +283 -0
  201. package/blocks/nurture/lib/nurture-types.ts +1 -1
  202. package/blocks/objects/README.md +17 -0
  203. package/blocks/objects/block.json +29 -16
  204. package/blocks/objects/components/object-not-found.tsx +3 -1
  205. package/blocks/objects/components/object-record-detail.tsx +2 -1
  206. package/blocks/objects/components/object-schema-editor.tsx +3 -1
  207. package/blocks/objects/components/object-table.tsx +13 -3
  208. package/blocks/objects/components/objects-screen.tsx +147 -0
  209. package/blocks/objects/lib/object-types.ts +1 -1
  210. package/blocks/pages/README.md +12 -0
  211. package/blocks/pages/block.json +35 -22
  212. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  213. package/blocks/pages/components/content-library.tsx +46 -11
  214. package/blocks/pages/components/page-list.tsx +6 -3
  215. package/blocks/pages/components/pages-screen.tsx +154 -0
  216. package/blocks/quotes/README.md +17 -0
  217. package/blocks/quotes/block.json +38 -9
  218. package/blocks/quotes/components/product-picker.tsx +3 -1
  219. package/blocks/quotes/components/quote-list.tsx +7 -3
  220. package/blocks/quotes/components/quote-related.tsx +123 -0
  221. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  222. package/blocks/radar/README.md +10 -0
  223. package/blocks/radar/block.json +41 -1
  224. package/blocks/radar/components/competitor-detail.tsx +117 -0
  225. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  226. package/blocks/radar/components/radar-report.tsx +4 -2
  227. package/blocks/radar/components/radar-screen.tsx +214 -0
  228. package/blocks/radar/components/report-detail.tsx +104 -0
  229. package/blocks/search/README.md +10 -0
  230. package/blocks/search/block.json +16 -1
  231. package/blocks/search/components/filter-builder.tsx +3 -3
  232. package/blocks/search/components/prospect-results.tsx +5 -3
  233. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  234. package/blocks/search/components/saved-searches.tsx +4 -1
  235. package/blocks/search/hooks/use-filter-options.ts +20 -6
  236. package/blocks/search/lib/search-types.ts +2 -0
  237. package/blocks/sequencer/README.md +49 -9
  238. package/blocks/sequencer/block.json +99 -19
  239. package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
  240. package/blocks/sequencer/components/sequence-builder.tsx +211 -46
  241. package/blocks/sequencer/components/sequence-detail.tsx +259 -0
  242. package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
  243. package/blocks/sequencer/components/sequence-list.tsx +33 -8
  244. package/blocks/sequencer/components/sequence-stats.tsx +3 -2
  245. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  246. package/blocks/sequencer/components/sequences-screen.tsx +159 -0
  247. package/blocks/sequencer/components/step-editor.tsx +3 -1
  248. package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
  249. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  250. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  251. package/blocks/social-ads/README.md +11 -0
  252. package/blocks/social-ads/block.json +101 -2
  253. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  254. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  255. package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
  256. package/blocks/social-ads/components/audience-sync.tsx +11 -7
  257. package/blocks/social-ads/components/campaign-detail.tsx +133 -0
  258. package/blocks/social-ads/components/creative-detail.tsx +150 -0
  259. package/blocks/social-ads/components/creative-gallery.tsx +15 -5
  260. package/blocks/social-ads/components/post-composer.tsx +3 -1
  261. package/blocks/social-ads/components/post-detail.tsx +160 -0
  262. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  263. package/blocks/social-ads/components/social-screen.tsx +160 -0
  264. package/blocks/visitors/README.md +10 -0
  265. package/blocks/visitors/block.json +25 -1
  266. package/blocks/visitors/components/snippet-install.tsx +3 -1
  267. package/blocks/visitors/components/visitor-company-card.tsx +2 -2
  268. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  269. package/blocks/visitors/components/visitor-feed.tsx +11 -2
  270. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  271. package/blocks/webchat/README.md +10 -0
  272. package/blocks/webchat/block.json +21 -7
  273. package/blocks/webchat/components/chat-widget-config.tsx +7 -4
  274. package/blocks/webchat/components/conversation-list.tsx +7 -3
  275. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  276. package/blocks/workbench/README.md +11 -0
  277. package/blocks/workbench/block.json +48 -1
  278. package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
  279. package/blocks/workbench/components/workbench-grid.tsx +9 -3
  280. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  281. package/blocks/workbench/components/workbench-screen.tsx +192 -0
  282. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  283. package/blocks/workflows/README.md +13 -0
  284. package/blocks/workflows/block.json +28 -9
  285. package/blocks/workflows/components/run-history.tsx +31 -9
  286. package/blocks/workflows/components/trigger-config.tsx +3 -1
  287. package/blocks/workflows/components/workflow-editor.tsx +3 -1
  288. package/blocks/workflows/components/workflow-list.tsx +13 -6
  289. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  290. package/dist/cli.js +2785 -1637
  291. package/dist/errors.d.mts +34 -0
  292. package/dist/errors.d.ts +34 -0
  293. package/dist/errors.js +46 -0
  294. package/dist/errors.js.map +1 -0
  295. package/dist/errors.mjs +21 -0
  296. package/dist/errors.mjs.map +1 -0
  297. package/dist/index.browser.js +11 -5
  298. package/dist/index.browser.js.map +1 -1
  299. package/dist/index.browser.mjs +11 -5
  300. package/dist/index.browser.mjs.map +1 -1
  301. package/dist/index.d.mts +164 -25
  302. package/dist/index.d.ts +164 -25
  303. package/dist/index.js +11 -5
  304. package/dist/index.js.map +1 -1
  305. package/dist/index.mjs +11 -5
  306. package/dist/index.mjs.map +1 -1
  307. package/dist/react.browser.js +10 -4
  308. package/dist/react.browser.js.map +1 -1
  309. package/dist/react.browser.mjs +10 -4
  310. package/dist/react.browser.mjs.map +1 -1
  311. package/dist/react.d.mts +142 -5
  312. package/dist/react.d.ts +142 -5
  313. package/dist/react.js +10 -4
  314. package/dist/react.js.map +1 -1
  315. package/dist/react.mjs +10 -4
  316. package/dist/react.mjs.map +1 -1
  317. package/dist/types/blocks/add.d.ts +11 -4
  318. package/dist/types/blocks/brand.d.ts +113 -0
  319. package/dist/types/blocks/compose.d.ts +53 -0
  320. package/dist/types/blocks/create.d.ts +25 -8
  321. package/dist/types/blocks/doctor.d.ts +6 -1
  322. package/dist/types/blocks/flows.d.ts +20 -0
  323. package/dist/types/blocks/navfile.d.ts +35 -0
  324. package/dist/types/blocks/paths.d.ts +4 -0
  325. package/dist/types/blocks/prefixes.d.ts +37 -0
  326. package/dist/types/blocks/registry.d.ts +32 -0
  327. package/dist/types/blocks/screens.d.ts +53 -0
  328. package/dist/types/cli.d.ts +2 -0
  329. package/dist/types/errors.d.ts +33 -0
  330. package/dist/types/http.d.ts +2 -18
  331. package/dist/types/version.d.ts +2 -2
  332. package/docs/agents.md +15 -4
  333. package/docs/api/README.md +2 -2
  334. package/docs/api/by-path.md +1 -0
  335. package/docs/api/desk.d.ts +98 -2
  336. package/docs/api/desk.md +123 -1
  337. package/docs/api/inbox.d.ts +10 -2
  338. package/docs/api/inbox.md +10 -2
  339. package/docs/api/seo.d.ts +19 -1
  340. package/docs/api/seo.md +24 -1
  341. package/package.json +16 -2
  342. package/stacks/6sense.json +41 -16
  343. package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
  344. package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
  345. package/stacks/apollo.json +118 -36
  346. package/stacks/calendly.json +60 -0
  347. package/stacks/clay/pages/companies.tsx.tmpl +15 -0
  348. package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
  349. package/stacks/clay.json +59 -26
  350. package/stacks/gong.json +81 -35
  351. package/stacks/hootsuite.json +99 -0
  352. package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
  353. package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
  354. package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
  355. package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
  356. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  357. package/stacks/hubspot.json +167 -66
  358. package/stacks/intercom.json +48 -20
  359. package/stacks/mailchimp.json +52 -33
  360. package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
  361. package/stacks/outreach.json +88 -25
  362. package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
  363. package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
  364. package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
  365. package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
  366. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  367. package/stacks/pipedrive.json +115 -0
  368. package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
  369. package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
  370. package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
  371. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  372. package/stacks/salesforce.json +81 -31
  373. package/stacks/stack.schema.json +6 -1
  374. package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
  375. package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
  376. package/stacks/zoominfo.json +102 -0
  377. package/styles/styles.css +1 -1
  378. package/templates/next-app/README.md.tmpl +55 -8
  379. package/templates/next-app/app/globals.css.tmpl +105 -0
  380. package/templates/next-app/app/layout.tsx.tmpl +18 -4
  381. package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
  382. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  383. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  384. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  385. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  386. package/templates/next-app/dot-gitignore.tmpl +2 -0
  387. package/templates/next-app/lib/nav.ts.tmpl +1 -1
  388. package/templates/next-app/package.json.tmpl +1 -1
@@ -28,6 +28,7 @@ import { STATUS_LABEL, formLabel, formStatus, pageUrlText, pickItems } from "../
28
28
  import type { FormListItem, FormOverview, FormRef } from "../lib/form-types";
29
29
  import { FormMappingEditor } from "./form-mapping-editor";
30
30
  import { SubmissionsTable } from "./submissions-table";
31
+ import { useBrand } from "@/g8/hooks/use-brand";
31
32
 
32
33
  export interface FormBuilderProps {
33
34
  /** Form to open first. */
@@ -83,6 +84,7 @@ function FormHeader({ form, onChanged }: { form: FormListItem; onChanged: () =>
83
84
  }
84
85
 
85
86
  export function FormBuilder({ initialForm, onSelectForm }: FormBuilderProps) {
87
+ const brand = useBrand();
86
88
  const [page, setPage] = useState(1);
87
89
  const [searchText, setSearchText] = useState("");
88
90
  const [search, setSearch] = useState("");
@@ -109,7 +111,7 @@ export function FormBuilder({ initialForm, onSelectForm }: FormBuilderProps) {
109
111
  <Button type="submit" size="sm" variant="outline">Search</Button>
110
112
  </form>
111
113
  {forms.loading && !forms.data ? <div className="grid gap-2" aria-busy="true"><Skeleton className="h-12 w-full" /><Skeleton className="h-12 w-full" /></div> : items.length === 0 ? (
112
- <EmptyState title={search ? "No forms match" : "No forms captured yet"} description={search ? "Try another name." : "Install the graph8 snippet on your site (see FormEmbed). Forms appear here after their first submission."} />
114
+ <EmptyState title={search ? "No forms match" : "No forms captured yet"} description={search ? "Try another name." : `Install the ${brand.linkLabel} snippet on your site (see FormEmbed). Forms appear here after their first submission.`} />
113
115
  ) : (
114
116
  <ul className="grid gap-1">
115
117
  {items.map((f) => {
@@ -15,6 +15,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle, Label, Skeleton } fro
15
15
  import { ApiError } from "@/g8/components/api-error";
16
16
  import { g8Get } from "@/g8/lib/g8-client";
17
17
  import { useG8Query } from "@/g8/hooks/use-g8-query";
18
+ import { useBrand } from "@/g8/hooks/use-brand";
18
19
 
19
20
  export interface FormEmbedProps {
20
21
  /** Stream (site) to read the snippet for. Omit for the organization's main stream. */
@@ -55,6 +56,7 @@ function CodeBlock({ label, code }: { label: string; code: string }) {
55
56
  }
56
57
 
57
58
  export function FormEmbed({ streamId }: FormEmbedProps) {
59
+ const brand = useBrand();
58
60
  const [stream, setStream] = useState<string | undefined>(streamId);
59
61
  const snippet = useG8Query((signal) => g8Get<Snippet>("/snippet", { stream_id: stream }, { signal }).then((r) => r.data), [stream]);
60
62
  const template = useG8Query((signal) => g8Get<{ html?: string }>("/snippet/form", { stream_id: stream }, { signal }).then((r) => r.data), [stream]);
@@ -80,7 +82,7 @@ export function FormEmbed({ streamId }: FormEmbedProps) {
80
82
  <Card>
81
83
  <CardHeader><CardTitle>1. Install the tracking snippet</CardTitle></CardHeader>
82
84
  <CardContent className="grid gap-3">
83
- <p className="text-muted-foreground text-sm">Put this on every page that has a form. graph8 then detects the forms and records submissions. Allowed domains: {s?.domains?.join(", ") || "any"}.</p>
85
+ <p className="text-muted-foreground text-sm">Put this on every page that has a form. {brand.linkLabel} then detects the forms and records submissions. Allowed domains: {s?.domains?.join(", ") || "any"}.</p>
84
86
  {s?.script_tag ? <CodeBlock label="Script tag" code={s.script_tag} /> : null}
85
87
  {s?.react_snippet ? <CodeBlock label="React component" code={s.react_snippet} /> : null}
86
88
  <p className="text-muted-foreground text-xs">Write key: <code>{s?.write_key}</code> (public, safe in page source).</p>
@@ -0,0 +1,353 @@
1
+ "use client";
2
+
3
+ /**
4
+ * FormsScreen: the finished forms workspace. No props needed.
5
+ *
6
+ * graph8 captures the lead forms already on your site (it does not host a form designer), so this screen is
7
+ * about getting the code on the site, routing every captured form to a list, and watching what arrives.
8
+ *
9
+ * Composes: ScreenLayout (title, tabs, stats), Toolbar (debounced search, health filter), SplitView +
10
+ * DetailPanel (selected form, deep link `?id=<source_id>/<form_key>`, one submission `&submission=<id>`), FormEmbed
11
+ * (Install tab, `?tab=install`), SiteAuditPanel (Site audit tab, `?tab=audit`), FormMappingEditor (Setup tab),
12
+ * SubmissionsTable (Submissions tab).
13
+ *
14
+ * User flows: install the snippet and copy the ready-made lead form, see every captured form with its health and
15
+ * counts, search and filter, open one to read capture and delivery numbers, rename it, pause or resume delivery
16
+ * (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
+ *
19
+ * SDK operations: listForms, getFormOverview, updateFormRegistry, pauseFormDelivery / resumeFormDelivery
20
+ * (external, confirmed), searchFormSubmissions (read, to resolve a `&submission=` deep link that was not clicked
21
+ * in this session); the rest live in the composed parts.
22
+ */
23
+
24
+ import { useEffect, useState } from "react";
25
+ import type { ReactNode } from "react";
26
+ import { ClipboardList, Pause, Play } from "lucide-react";
27
+ import { Button, Input, Skeleton } from "@graph8/sdk/ui";
28
+ import { ApiError } from "@/g8/components/api-error";
29
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
30
+ import { SectionCard } from "@/g8/components/screen/section-card";
31
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
32
+ import { EmptyState } from "@/g8/components/empty-state";
33
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
34
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
35
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
36
+ import { SplitView } from "@/g8/components/screen/split-view";
37
+ import { StatusPill } from "@/g8/components/screen/status-pill";
38
+ import type { Tone } from "@/g8/components/screen/status-pill";
39
+ import { Toolbar } from "@/g8/components/screen/toolbar";
40
+ import { useUrlState } from "@/g8/hooks/use-url-state";
41
+ import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
42
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
43
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
44
+ import { STATUS_LABEL, cellText, formLabel, formStatus, pageUrlText, pickItems, submissionFailed, submissionState } from "../lib/form-types";
45
+ import type { FormListItem, FormOverview, FormRef, FormSubmission } from "../lib/form-types";
46
+ import { FormEmbed } from "./form-embed";
47
+ import { FormMappingEditor } from "./form-mapping-editor";
48
+ import { SiteAuditPanel } from "./site-audit-panel";
49
+ import { SubmissionsTable } from "./submissions-table";
50
+ import { pluralWord } from "@/g8/lib/plural";
51
+ import { useBrand } from "@/g8/hooks/use-brand";
52
+
53
+ export interface FormsScreenProps {
54
+ renderRowActions?: (form: FormListItem) => ReactNode;
55
+ renderDetailActions?: (form: FormListItem) => ReactNode;
56
+ extraDetailTabs?: { id: string; label: string; render: (form: FormListItem) => ReactNode }[];
57
+ toolbarActions?: ReactNode;
58
+ className?: string;
59
+ }
60
+
61
+ type Tab = "forms" | "install" | "audit";
62
+ const LIMIT = 100;
63
+ /** How many of a form's latest submissions a deep link is looked up in. */
64
+ const SUBMISSION_LOOKUP = 100;
65
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
66
+
67
+ const TONE: Record<ReturnType<typeof formStatus>, Tone> = { failing: "negative", paused: "warning", live: "positive", needs_setup: "info", no_data: "neutral" };
68
+
69
+ const refKey = (f: FormRef): string => `${encodeURIComponent(f.source_id)}/${encodeURIComponent(f.form_key)}`;
70
+
71
+ function parseRef(value: string): FormRef | undefined {
72
+ const [source, key] = value.split("/");
73
+ return source && key ? { source_id: decodeURIComponent(source), form_key: decodeURIComponent(key) } : undefined;
74
+ }
75
+
76
+ function FormOverviewTab({ form, onChanged }: { form: FormListItem; onChanged: () => void }) {
77
+ const base = `/forms/${encodeURIComponent(form.form_key)}`;
78
+ const overview = useG8Query((signal) => g8Get<FormOverview>(`${base}/overview`, { source_id: form.source_id }, { signal }).then((r) => r.data), [base, form.source_id]);
79
+ const [name, setName] = useState(form.display_name ?? "");
80
+ const [confirm, setConfirm] = useState<"pause" | "resume" | null>(null);
81
+ const rename = useG8Mutation(() => g8Patch(base, { source_id: form.source_id, display_name: name.trim() }), { onSuccess: onChanged });
82
+ const toggle = useG8Mutation((action: "pause" | "resume") => g8Post(`${base}/delivery/${action}`, { source_id: form.source_id }), {
83
+ onSuccess: () => { setConfirm(null); void overview.refetch(); onChanged(); },
84
+ });
85
+ if (overview.error) return <ApiError error={overview.error} title="Could not load the form overview" onRetry={() => void overview.refetch()} />;
86
+ if (overview.loading && !overview.data) return <Skeleton className="h-32 w-full" aria-busy="true" />;
87
+ const o = overview.data;
88
+ const paused = o?.delivery_health === "paused";
89
+ const m = o?.metrics;
90
+ return (
91
+ <div className="grid gap-4">
92
+ <form className="flex items-end gap-2" onSubmit={(e) => { e.preventDefault(); if (name.trim()) void rename.mutate(undefined); }}>
93
+ <div className="max-w-sm flex-1">
94
+ <label htmlFor="g8-forms-name" className="text-muted-foreground text-xs">Form name</label>
95
+ <Input id="g8-forms-name" value={name} placeholder={form.form_key} onChange={(e) => setName(e.target.value)} />
96
+ </div>
97
+ <Button type="submit" size="sm" variant="outline" loading={rename.loading} disabled={!name.trim() || name.trim() === form.display_name}>Rename</Button>
98
+ <Button type="button" size="sm" variant="outline" disabled={!o} onClick={() => setConfirm(paused ? "resume" : "pause")}>
99
+ {paused ? <Play className="size-4" /> : <Pause className="size-4" />}{paused ? "Resume delivery" : "Pause delivery"}
100
+ </Button>
101
+ </form>
102
+ {rename.error ? <ApiError error={rename.error} title="Could not rename the form" /> : null}
103
+ {o?.delivery_warning ? <p role="alert" className="flex items-center gap-2 text-sm"><StatusPill tone="warning">Check delivery</StatusPill>{o.delivery_warning}</p> : null}
104
+ <KeyValueList
105
+ columns={2}
106
+ items={[
107
+ { label: "Captured", value: (m?.captured ?? 0).toLocaleString() },
108
+ { label: "Processed", value: (m?.processed ?? 0).toLocaleString() },
109
+ { label: "In destination", value: (m?.destination_verified ?? 0).toLocaleString() },
110
+ { label: "Failed", value: (m?.failed ?? 0).toLocaleString() },
111
+ { label: "Destination list", value: o?.destination?.list_name ?? "None yet" },
112
+ { label: "Capture health", value: o?.capture_health ?? "-" },
113
+ { label: "Page", value: pageUrlText(form.page_url) || "-" },
114
+ { label: "Next step", value: o?.recommended_action ?? "-" },
115
+ ]}
116
+ />
117
+ <ConfirmSpendDialog
118
+ open={confirm !== null}
119
+ tier="external"
120
+ title={confirm === "pause" ? "Pause delivery for this form?" : "Resume delivery for this form?"}
121
+ confirmLabel={confirm === "pause" ? "Pause" : "Resume"}
122
+ description={<><p>{confirm === "pause" ? "New submissions are still captured but queue instead of reaching your list." : "Queued and new submissions are delivered to the destination list, creating or updating contacts."}</p>{toggle.error ? <ApiError error={toggle.error} title="Could not change delivery" /> : null}</>}
123
+ confirming={toggle.loading}
124
+ onCancel={() => setConfirm(null)}
125
+ onConfirm={() => { if (confirm) void toggle.mutate(confirm); }}
126
+ />
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /** A linked submission that is not among the form's latest ones: say so, and still show its real trace. */
132
+ function SubmissionNotFound({ form, submissionId }: { form: FormRef; submissionId: string }) {
133
+ const trace = useG8Query(
134
+ (signal) => g8Get<{ capture_state?: string; receipts?: unknown[] }>(`/forms/${encodeURIComponent(form.form_key)}/submissions/${encodeURIComponent(submissionId)}/trace`, { source_id: form.source_id }, { signal }).then((r) => r.data),
135
+ [form.form_key, form.source_id, submissionId],
136
+ );
137
+ const receipts = pickItems<Record<string, unknown>>(trace.data, "receipts");
138
+ return (
139
+ <div className="grid gap-4">
140
+ <EmptyState
141
+ title="This submission is not in the latest 100"
142
+ description={`Submission ${submissionId} is older than the form's latest ${SUBMISSION_LOOKUP} submissions, so its fields and delivery are not shown here. Open it from the Submissions tab to see them and to retry a failed delivery.`}
143
+ />
144
+ <SectionCard title="Where did this lead go?" description={trace.data?.capture_state ? `Capture: ${trace.data.capture_state}` : undefined}>
145
+ {trace.error ? <ApiError error={trace.error} title="No trace for this submission" /> : trace.loading ? <Skeleton className="h-16 w-full" /> : receipts.length === 0 ? <p className="text-muted-foreground text-sm">No delivery steps recorded yet.</p> : (
146
+ receipts.map((r, i) => <pre key={i} className="overflow-x-auto rounded border p-2 text-xs">{JSON.stringify(r, null, 2)}</pre>)
147
+ )}
148
+ </SectionCard>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ /** One submission in full: every submitted field, where it came from, its delivery, and the trace. */
154
+ function SubmissionDetail({ form, submission, onChanged }: { form: FormRef; submission: FormSubmission; onChanged: () => void }) {
155
+ const [retrying, setRetrying] = useState(false);
156
+ const trace = useG8Query(
157
+ (signal) => g8Get<{ capture_state?: string; receipts?: unknown[] }>(`/forms/${encodeURIComponent(form.form_key)}/submissions/${encodeURIComponent(submission.id)}/trace`, { source_id: form.source_id }, { signal }).then((r) => r.data),
158
+ [form.form_key, form.source_id, submission.id],
159
+ );
160
+ const retry = useG8Mutation(() => g8Post(`/forms/deliveries/${encodeURIComponent(submission.delivery?.receipt_id ?? "")}/retry`), { onSuccess: () => { setRetrying(false); void trace.refetch(); onChanged(); } });
161
+ const fields = Object.entries(submission.form_data ?? {});
162
+ const receipts = pickItems<Record<string, unknown>>(trace.data, "receipts");
163
+ const failed = submissionFailed(submission);
164
+ return (
165
+ <div className="grid gap-4">
166
+ <div className="flex items-center gap-2">
167
+ <StatusPill tone={failed ? "negative" : submissionState(submission) === "delivered" ? "positive" : "neutral"}>{submissionState(submission)}</StatusPill>
168
+ {submission.is_test ? <StatusPill tone="info">Test</StatusPill> : null}
169
+ {failed && submission.delivery?.receipt_id ? <Button size="sm" variant="outline" onClick={() => { retry.reset(); setRetrying(true); }}>Retry delivery</Button> : null}
170
+ </div>
171
+ <SectionCard title="Submitted fields">
172
+ {fields.length === 0 ? <p className="text-muted-foreground text-sm">No fields were captured. Identifying fields may be redacted for your key.</p> : (
173
+ <KeyValueList columns={1} items={fields.map(([k, v]) => ({ label: k, value: cellText(v) || "Empty" }))} />
174
+ )}
175
+ </SectionCard>
176
+ <SectionCard title="Where it came from">
177
+ <KeyValueList
178
+ columns={2}
179
+ items={[
180
+ { label: "Received", value: submission.received_at ? new Date(submission.received_at).toLocaleString() : "Not set" },
181
+ { label: "Page", value: submission.page?.url ?? submission.page?.path ?? "Not set" },
182
+ { label: "Delivery", value: submissionState(submission) },
183
+ { label: "Reason", value: submission.delivery?.reason ?? "None" },
184
+ { label: "Receipt", value: submission.delivery?.receipt_id ?? "None" },
185
+ { label: "Submission id", value: submission.id },
186
+ ]}
187
+ />
188
+ </SectionCard>
189
+ <SectionCard title="Where did this lead go?" description={trace.data?.capture_state ? `Capture: ${trace.data.capture_state}` : undefined}>
190
+ {trace.error ? <ApiError error={trace.error} title="No trace for this submission" /> : trace.loading ? <Skeleton className="h-16 w-full" /> : receipts.length === 0 ? <p className="text-muted-foreground text-sm">No delivery steps recorded yet.</p> : (
191
+ receipts.map((r, i) => <pre key={i} className="overflow-x-auto rounded border p-2 text-xs">{JSON.stringify(r, null, 2)}</pre>)
192
+ )}
193
+ </SectionCard>
194
+ <ConfirmSpendDialog
195
+ open={retrying}
196
+ tier="external"
197
+ title="Retry delivering this lead?"
198
+ description={<><p>The lead is sent to the form's destination again, creating or updating a contact.</p>{retry.error ? <ApiError error={retry.error} title="Could not retry" /> : null}</>}
199
+ confirmLabel="Retry delivery"
200
+ confirming={retry.loading}
201
+ onCancel={() => { retry.reset(); setRetrying(false); }}
202
+ onConfirm={() => void retry.mutate(undefined)}
203
+ />
204
+ </div>
205
+ );
206
+ }
207
+
208
+ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: FormsScreenProps) {
209
+ const brand = useBrand();
210
+ const [tabParam, setTabParam] = useUrlState("tab", "forms");
211
+ const tab: Tab = tabParam === "install" || tabParam === "audit" ? tabParam : "forms";
212
+ const setTab = (next: Tab): void => setTabParam(next === "forms" ? null : next);
213
+ const [selectedKey, setSelectedKey] = useUrlState("id", "");
214
+ const [view, setView] = useUrlState("view", "");
215
+ const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
216
+ const [text, setText] = useState("");
217
+ const [search, setSearch] = useState("");
218
+ const [health, setHealth] = useState("");
219
+ const [submissionId, setSubmissionId] = useUrlState("submission", "");
220
+ const [openedSubmission, setOpenedSubmission] = useState<FormSubmission | null>(null);
221
+ const [subsRefresh, setSubsRefresh] = useState(0);
222
+
223
+ // Debounce the search so each keystroke is not a request.
224
+ useEffect(() => {
225
+ const t = setTimeout(() => setSearch(text.trim()), 300);
226
+ return () => clearTimeout(t);
227
+ }, [text]);
228
+
229
+ 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;
236
+ const shown = items.filter((f) => !health || formStatus(f) === health);
237
+ const ref = parseRef(selectedKey);
238
+ 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;
239
+ const sum = (pick: (f: FormListItem) => number | null | undefined): number => items.reduce((a, f) => a + (pick(f) ?? 0), 0);
240
+ // A `&submission=` link that was not clicked in this session (reload, shared link): find the real record in the
241
+ // form's latest submissions instead of rendering an empty stub as if it were data.
242
+ const needsLookup = Boolean(ref && submissionId) && openedSubmission?.id !== submissionId;
243
+ const lookup = useG8Query(
244
+ (signal) => g8Post<unknown>(`/forms/${encodeURIComponent(ref?.form_key ?? "")}/submissions`, { page: 1, per_page: SUBMISSION_LOOKUP, source_id: ref?.source_id }, { signal }).then((r) => {
245
+ const data = r.data as { submissions?: { items?: FormSubmission[] }; items?: FormSubmission[] } | undefined;
246
+ return (data?.submissions ?? data)?.items ?? [];
247
+ }),
248
+ [ref?.form_key, ref?.source_id, submissionId],
249
+ { enabled: needsLookup },
250
+ );
251
+ const linkedSubmission: FormSubmission | null = !submissionId ? null : openedSubmission?.id === submissionId ? openedSubmission : ((lookup.data ?? []).find((x) => x.id === submissionId) ?? null);
252
+
253
+ const rows = forms.error ? <ApiError error={forms.error} title="Could not load forms" onRetry={() => void forms.refetch()} /> : forms.loading && !forms.data ? (
254
+ <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
+ ) : shown.length === 0 ? (
256
+ <EmptyState
257
+ icon={<ClipboardList className="size-8" />}
258
+ title={search || health ? "No forms match" : "No forms captured yet"}
259
+ description={search || health ? "Try another name or health." : `Install the ${brand.linkLabel} snippet on your site. Forms appear here after their first submission.`}
260
+ action={!search && !health ? <Button onClick={() => setTab("install")}>Get the install code</Button> : undefined}
261
+ />
262
+ ) : (
263
+ <ul className="grid gap-2">
264
+ {shown.map((f) => {
265
+ const status = formStatus(f);
266
+ const key = refKey(f);
267
+ return (
268
+ <li key={key} className={`flex items-center justify-between gap-3 rounded-md border p-3 ${key === selectedKey ? "bg-muted" : ""}`}>
269
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setSelectedKey(key)}>
270
+ <span className="block truncate font-medium">{formLabel(f)}</span>
271
+ <span className="text-muted-foreground block truncate text-xs">{pageUrlText(f.page_url) || f.form_key}</span>
272
+ <span className="text-muted-foreground block text-xs">
273
+ {(f.total_submissions ?? 0).toLocaleString()} {pluralWord(f.total_submissions ?? 0, "submission")}{f.failed_submissions ? ` · ${f.failed_submissions} failed` : ""}{f.target_list_name ? ` · to ${f.target_list_name}` : ""}
274
+ </span>
275
+ </button>
276
+ <StatusPill tone={TONE[status]}>{STATUS_LABEL[status]}</StatusPill>
277
+ {renderRowActions?.(f)}
278
+ </li>
279
+ );
280
+ })}
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}
282
+ </ul>
283
+ );
284
+
285
+ const detail = selected ? (
286
+ <DetailPanel
287
+ title={formLabel(selected)}
288
+ subtitle={pageUrlText(selected.page_url) || selected.form_key}
289
+ badges={<StatusPill tone={TONE[formStatus(selected)]}>{STATUS_LABEL[formStatus(selected)]}</StatusPill>}
290
+ actions={renderDetailActions?.(selected)}
291
+ onClose={() => closeForm()}
292
+ {...expandProps}
293
+ 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); }} /> },
297
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
298
+ ]}
299
+ />
300
+ ) : null;
301
+
302
+ return (
303
+ <ScreenLayout
304
+ className={className}
305
+ title="Forms"
306
+ description="Capture the forms already on your site, route every lead to a list, and watch what arrives."
307
+ icon={<ClipboardList className="size-5" />}
308
+ tabs={[{ id: "forms", label: "Forms", count: total }, { id: "install", label: "Install" }, { id: "audit", label: "Site audit" }]}
309
+ activeTab={tab}
310
+ onTabChange={(id) => { setTab(id as Tab); closeForm(); }}
311
+ stats={tab === "forms" ? [
312
+ { label: "Forms", value: total.toLocaleString() },
313
+ { label: "Submissions", value: sum((f) => f.total_submissions).toLocaleString() },
314
+ { label: "Failed", value: sum((f) => f.failed_submissions).toLocaleString(), tone: sum((f) => f.failed_submissions) > 0 ? "negative" : "neutral" },
315
+ { label: "Need setup", value: items.filter((f) => formStatus(f) === "needs_setup").length.toLocaleString(), tone: "info" },
316
+ ] : undefined}
317
+ toolbar={tab === "forms" ? (
318
+ <Toolbar
319
+ search={{ value: text, onChange: setText, placeholder: "Search forms" }}
320
+ filters={(
321
+ <select aria-label="Form health" className={SELECT} value={health} onChange={(e) => setHealth(e.target.value)}>
322
+ <option value="">All health</option>
323
+ {Object.entries(STATUS_LABEL).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
324
+ </select>
325
+ )}
326
+ actions={toolbarActions}
327
+ />
328
+ ) : undefined}
329
+ >
330
+ <FormSheet
331
+ open={Boolean(submissionId) && Boolean(selected)}
332
+ onOpenChange={(o) => { if (!o) { setSubmissionId(null); setOpenedSubmission(null); } }}
333
+ title="Submission"
334
+ description={selected ? formLabel(selected) : undefined}
335
+ >
336
+ {selected && submissionId ? (
337
+ linkedSubmission ? (
338
+ <SubmissionDetail key={submissionId} form={selected} submission={linkedSubmission} onChanged={() => setSubsRefresh((n) => n + 1)} />
339
+ ) : lookup.error ? (
340
+ <ApiError error={lookup.error} title="Could not load this submission" onRetry={() => void lookup.refetch()} />
341
+ ) : lookup.loading || !lookup.data ? (
342
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-8 w-40" /><Skeleton className="h-32 w-full" /></div>
343
+ ) : (
344
+ <SubmissionNotFound form={selected} submissionId={submissionId} />
345
+ )
346
+ ) : null}
347
+ </FormSheet>
348
+ {tab === "install" ? <FormEmbed /> : tab === "audit" ? <SiteAuditPanel /> : (
349
+ <SplitView list={rows} detail={detail} detailOpen={Boolean(selected)} onCloseDetail={() => closeForm()} expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)} detailWidth="xl" />
350
+ )}
351
+ </ScreenLayout>
352
+ );
353
+ }
@@ -21,6 +21,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
21
21
  import { useG8Query } from "@/g8/hooks/use-g8-query";
22
22
  import { cellText, pickItems, submissionFailed, submissionState } from "../lib/form-types";
23
23
  import type { FormRef, FormSubmission } from "../lib/form-types";
24
+ import { useBrand } from "@/g8/hooks/use-brand";
24
25
 
25
26
  export interface SubmissionsTableProps {
26
27
  /** The form to show. Omit it (for example as a stack nav page) and a form picker is shown instead. */
@@ -29,6 +30,10 @@ export interface SubmissionsTableProps {
29
30
  pageSize?: number;
30
31
  /** Max submitted-field columns. Default 4. */
31
32
  maxFieldColumns?: number;
33
+ /** When set, the received time of a row is a button that opens the full record (see FormsScreen). */
34
+ onOpen?: (submission: FormSubmission) => void;
35
+ /** The submission whose full record is open; its row is highlighted. */
36
+ selectedId?: string;
32
37
  }
33
38
 
34
39
  interface Page { items: FormSubmission[]; total: number }
@@ -59,14 +64,15 @@ function SubmissionTrace({ form, submission, onClose }: { form: FormRef; submiss
59
64
  );
60
65
  }
61
66
 
62
- export function SubmissionsTable({ form: given, pageSize = 20, maxFieldColumns = 4 }: SubmissionsTableProps) {
67
+ export function SubmissionsTable({ form: given, pageSize = 20, maxFieldColumns = 4, onOpen, selectedId }: SubmissionsTableProps) {
68
+ const brand = useBrand();
63
69
  const [picked, setPicked] = useState<FormRef | undefined>(undefined);
64
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 });
65
71
  const form = given ?? picked;
66
72
  if (!form) {
67
73
  if (forms.error) return <ApiError error={forms.error} title="Could not load forms" onRetry={() => void forms.refetch()} />;
68
74
  if (forms.loading && !forms.data) return <Skeleton className="h-9 w-64" aria-busy="true" />;
69
- if ((forms.data ?? []).length === 0) return <EmptyState title="No forms captured yet" description="Install the graph8 snippet on your site; forms appear after their first submission." />;
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.`} />;
70
76
  return (
71
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 }); }}>
72
78
  <option value="" disabled>Choose a form</option>
@@ -75,10 +81,10 @@ export function SubmissionsTable({ form: given, pageSize = 20, maxFieldColumns =
75
81
  );
76
82
  }
77
83
  // Keyed on the form: page, trace and retry state belong to one form.
78
- return <SubmissionsFor key={`${form.source_id}:${form.form_key}`} form={form} pageSize={pageSize} maxFieldColumns={maxFieldColumns} />;
84
+ return <SubmissionsFor key={`${form.source_id}:${form.form_key}`} form={form} pageSize={pageSize} maxFieldColumns={maxFieldColumns} onOpen={onOpen} selectedId={selectedId} />;
79
85
  }
80
86
 
81
- function SubmissionsFor({ form, pageSize, maxFieldColumns }: { form: FormRef; pageSize: number; maxFieldColumns: number }) {
87
+ function SubmissionsFor({ form, pageSize, maxFieldColumns, onOpen, selectedId }: { form: FormRef; pageSize: number; maxFieldColumns: number; onOpen?: (s: FormSubmission) => void; selectedId?: string }) {
82
88
  const [page, setPage] = useState(1);
83
89
  const [traceOf, setTraceOf] = useState<FormSubmission | null>(null);
84
90
  const [retrying, setRetrying] = useState<FormSubmission | null>(null);
@@ -123,8 +129,12 @@ function SubmissionsFor({ form, pageSize, maxFieldColumns }: { form: FormRef; pa
123
129
  </TableHeader>
124
130
  <TableBody>
125
131
  {items.map((s) => (
126
- <TableRow key={s.id}>
127
- <TableCell>{s.received_at ? new Date(s.received_at).toLocaleString() : ""}{s.is_test ? " (test)" : ""}</TableCell>
132
+ <TableRow key={s.id} data-state={selectedId === s.id ? "selected" : undefined}>
133
+ <TableCell>
134
+ {onOpen ? (
135
+ <button type="button" className="text-left hover:underline" aria-label={`Open submission ${s.id}`} onClick={() => onOpen(s)}>{s.received_at ? new Date(s.received_at).toLocaleString() : s.id}{s.is_test ? " (test)" : ""}</button>
136
+ ) : <>{s.received_at ? new Date(s.received_at).toLocaleString() : ""}{s.is_test ? " (test)" : ""}</>}
137
+ </TableCell>
128
138
  {columns.map((c) => <TableCell key={c}>{cellText(s.form_data?.[c])}</TableCell>)}
129
139
  <TableCell className={submissionFailed(s) ? "text-red-700" : undefined}>{submissionState(s)}</TableCell>
130
140
  <TableCell className="text-right">
@@ -7,6 +7,23 @@ copy `InboxScreen`, the finished two-pane page.
7
7
  Depends on: `crm` (the contact on each thread), `mailboxes` (the connected sending mailboxes). Install with
8
8
  `npx @graph8/sdk add inbox`.
9
9
 
10
+ ## Screens
11
+
12
+ `InboxScreen` (`components/inbox-screen.tsx`) is the finished page; it needs no props. Stat strip, channel / status /
13
+ tag filters, search of the loaded page, paged list, and the open conversation (reply composer with confirmed AI draft
14
+ and send, tag and assign). Open conversations deep-link as `?id=<channel>:<thread id>`. Optional slots:
15
+ `toolbarActions`, `renderRowActions(thread)`, `renderDetailActions(thread)`, `extraDetailTabs`, `pageSize`.
16
+ Needs scopes `inbox:read`, `inbox:write`, `inbox:run` (plus `campaigns:read` for the SMS sender picker) and a mailbox
17
+ connected in the graph8 app (a link-out card says so).
18
+
19
+ **Behaviour change.** Compared with the original two-pane `InboxScreen`, the finished page now writes `?id=` and
20
+ `?view=` to the host page URL and renders its own header (an "Inbox" h1, description and stats). If your page
21
+ already has a title or owns those query keys, pass `urlState={false}` (the open conversation lives in component
22
+ state, the URL is untouched) and/or `hideHeader` (no title or stats, just search, filters, Refresh and the two panes).
23
+ Defaults are unchanged from this release: both are on.
24
+
25
+ Every detail opens as a full page with `&view=full` (the Open full page button).
26
+
10
27
  ## The flow, in order
11
28
 
12
29
  1. **List** `GET /inbox?channel=&status=&page=&page_size=` (`InboxList`). Each row has `id` and `channel`.
@@ -18,8 +35,12 @@ Depends on: `crm` (the contact on each thread), `mailboxes` (the connected sendi
18
35
  `shorten` / `expand` / `fix_grammar_spelling` rework the text you pass in `message_html`. The response
19
36
  `data.content` is HTML and `data.plain_content` is text. It sends nothing.
20
37
  4. **Send** `POST /inbox/{reply_id}/send` (`ReplyComposer`, **external**, confirm first) with
21
- `{ channel, body, subject?, to? }`. The composer sends an `Idempotency-Key` so a double click cannot
22
- send twice.
38
+ `{ channel, body, subject?, to, from_address }`. `from_address` is the thread's own sender: `thread.mailbox`
39
+ (email), `thread.from_number` (SMS) or `thread.linkedin_account_id` (LinkedIn); `to` is the thread's contact
40
+ (`contact.email`, or `thread.to_number` for SMS). If an SMS or LinkedIn thread names no sender, the composer
41
+ shows a picker filled from `GET /sms/phone-numbers` or `GET /inbox/linkedin/accounts`. The API fills both
42
+ from the thread when omitted (older API versions refused the send with 400). The composer sends an
43
+ `Idempotency-Key` so a double click cannot send twice.
23
44
  5. **Route** (`TagAndAssign`): `POST /inbox/tags` creates a tag (write), `POST /inbox/{id}/tag?channel=`
24
45
  applies tag ids and `POST /inbox/{id}/assign?channel=` hands the thread to a teammate by email. Both of
25
46
  those are **external** (confirm first) because they can fire automations and notify people.
@@ -31,13 +52,14 @@ IDs passed between steps: `thread.id` + `thread.channel` (list -> view -> draft/
31
52
 
32
53
  - Connect a mailbox in the graph8 app (Google / Microsoft OAuth exists only there; see the `mailboxes` block).
33
54
  Until then the inbox has no email threads. `where: "app"`.
34
- - Give the proxy's API key `inbox:read`, `inbox:write` and `inbox:run`.
55
+ - Give the proxy's API key `inbox:read`, `inbox:write` and `inbox:run` (and `campaigns:read` for the SMS sender picker).
35
56
  - Credits for AI drafts (402 when none; there is no balance read, so the confirm dialog shows no estimate).
36
57
 
37
58
  ## Defaults (the agent does not need to ask)
38
59
 
39
60
  - AI drafts are only ever written into the reply box. Sending is always a separate, confirmed click.
40
61
  - Tone `professional`, goal `follow_up`.
62
+ - A reply leaves from the thread's own mailbox, number or LinkedIn account and goes to the thread's contact.
41
63
  - No assignee filter: the API cannot filter by an arbitrary assignee email (an org key owns no threads), so
42
64
  filter `thread.assignees` client-side if you need it.
43
65
  - Ask the human only about **tags**: there is no list-tags operation. Pass `availableTags` from your own
@@ -60,6 +82,8 @@ IDs passed between steps: `thread.id` + `thread.channel` (list -> view -> draft/
60
82
  | `inbox.getReply` | GET /inbox/{reply_id} | read | ThreadView, useInboxThread |
61
83
  | `aiInbox.generateInboxDraft` | POST /inbox/drafts/generate | billable | ReplyComposer |
62
84
  | `inbox.sendReply` | POST /inbox/{reply_id}/send | external | ReplyComposer |
85
+ | `channels.listSmsPhoneNumbers` | GET /sms/phone-numbers | read | ReplyComposer (SMS sender picker) |
86
+ | `aiInbox.listInboxLinkedinAccounts` | GET /inbox/linkedin/accounts | read | ReplyComposer (LinkedIn sender picker) |
63
87
  | `aiInbox.listInboxUsers` | GET /inbox/users | read | TagAndAssign |
64
88
  | `aiInbox.createInboxTag` | POST /inbox/tags | write | TagAndAssign |
65
89
  | `inbox.tagReply` | POST /inbox/{reply_id}/tag | external | TagAndAssign |
@@ -20,7 +20,9 @@
20
20
  "generate_inbox_draft_inbox_drafts_generate_post",
21
21
  "get_reply_inbox__reply_id__get",
22
22
  "list_inbox_inbox_get",
23
+ "list_inbox_linkedin_accounts_inbox_linkedin_accounts_get",
23
24
  "list_inbox_users_inbox_users_get",
25
+ "list_sms_phone_numbers_sms_phone_numbers_get",
24
26
  "send_reply_inbox__reply_id__send_post",
25
27
  "tag_reply_inbox__reply_id__tag_post"
26
28
  ],
@@ -50,12 +52,15 @@
50
52
  {
51
53
  "name": "ReplyComposer",
52
54
  "file": "components/reply-composer.tsx",
53
- "purpose": "Write a reply, draft or reshape it with AI (confirm, billable) and send it on the thread's channel (confirm, external).",
55
+ "purpose": "Write a reply, draft or reshape it with AI (confirm, billable) and send it from the thread's own mailbox, number or LinkedIn account to its contact (confirm, external); picks a sender when an SMS or LinkedIn thread names none.",
54
56
  "operations": [
55
57
  "generate_inbox_draft_inbox_drafts_generate_post",
58
+ "list_inbox_linkedin_accounts_inbox_linkedin_accounts_get",
59
+ "list_sms_phone_numbers_sms_phone_numbers_get",
56
60
  "send_reply_inbox__reply_id__send_post"
57
61
  ],
58
62
  "tiers": [
63
+ "read",
59
64
  "billable",
60
65
  "external"
61
66
  ]
@@ -157,6 +162,7 @@
157
162
  }
158
163
  ],
159
164
  "scopes": [
165
+ "campaigns:read",
160
166
  "inbox:read",
161
167
  "inbox:run",
162
168
  "inbox:write"
@@ -186,9 +192,15 @@
186
192
  "decision": "Filter by assignee",
187
193
  "default": "Not offered: the API cannot filter by an arbitrary assignee email (an org key owns no threads). Filter thread.assignees client-side",
188
194
  "askHuman": false
195
+ },
196
+ {
197
+ "decision": "Which mailbox, number or LinkedIn account a reply is sent from",
198
+ "default": "The thread's own (mailbox / from_number / linkedin_account_id on the thread); an SMS or LinkedIn thread without one shows a sender picker",
199
+ "askHuman": false
189
200
  }
190
201
  ],
191
202
  "peerDependencies": {
192
- "react": ">=18"
203
+ "react": ">=18",
204
+ "lucide-react": ">=0.400.0"
193
205
  }
194
206
  }
@@ -25,8 +25,12 @@ import {
25
25
  type InboxChannel,
26
26
  type InboxThread,
27
27
  } from "../lib/inbox-types";
28
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
29
+ import { RecordLink } from "@/g8/components/screen/record-link";
28
30
 
29
31
  export interface InboxListProps {
32
+ /** URL param the host screen reads for this record type. With it the name is a real link (Cmd-click opens it in a new tab); without it a plain button. */
33
+ linkParam?: string;
30
34
  onSelect?: (thread: InboxThread) => void;
31
35
  selectedId?: string | null;
32
36
  /** Start with these filters (for example a tag id from the URL). */
@@ -62,7 +66,7 @@ function LoadingRows() {
62
66
  );
63
67
  }
64
68
 
65
- export function InboxList({ onSelect, selectedId, initialFilters, pageSize = 25, onFiltersChange, className }: InboxListProps) {
69
+ export function InboxList({ linkParam, onSelect, selectedId, initialFilters, pageSize = 25, onFiltersChange, className }: InboxListProps) {
66
70
  const [filters, setFilters] = useState<InboxFilters>(initialFilters ?? {});
67
71
  const [page, setPage] = useState(1);
68
72
  const { threads, loading, error, hasNext, total, refetch } = useInboxThreads(filters, page, pageSize);
@@ -123,9 +127,9 @@ export function InboxList({ onSelect, selectedId, initialFilters, pageSize = 25,
123
127
  key={`${thread.channel}:${thread.id}`}
124
128
  data-state={selectedId === thread.id ? "selected" : undefined}
125
129
  className="cursor-pointer"
126
- onClick={() => onSelect?.(thread)}
130
+ {...rowActivationProps(() => onSelect?.(thread))}
127
131
  >
128
- <TableCell className="max-w-48 truncate font-medium">{contactLabel(thread)}</TableCell>
132
+ <TableCell className="max-w-48 truncate font-medium">{onSelect ? <RecordLink param={linkParam} id={thread.id} onOpen={() => onSelect(thread)}>{contactLabel(thread)}</RecordLink> : contactLabel(thread)}</TableCell>
129
133
  <TableCell className="max-w-80 truncate text-muted-foreground">
130
134
  {thread.subject ? <span className="text-foreground">{thread.subject} </span> : null}
131
135
  {previewText(thread)}