@graph8/sdk 0.361.0 → 0.370.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/README.md +14 -10
  2. package/blocks/_foundation/README.md +172 -1
  3. package/blocks/_foundation/block.json +234 -13
  4. package/blocks/_foundation/components/api-error.tsx +71 -25
  5. package/blocks/_foundation/components/brand-provider.tsx +23 -0
  6. package/blocks/_foundation/components/confirmed-action.tsx +90 -0
  7. package/blocks/_foundation/components/connect-graph8.tsx +280 -0
  8. package/blocks/_foundation/components/empty-state.tsx +39 -7
  9. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  10. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  11. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  12. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  13. package/blocks/_foundation/components/screen/index.ts +22 -0
  14. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  15. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  16. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  17. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  18. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  19. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  20. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  21. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  22. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  23. package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
  24. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  25. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  26. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  27. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  28. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  29. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  30. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  31. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  32. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  33. package/blocks/_foundation/lib/g8-client.ts +1 -1
  34. package/blocks/_foundation/lib/plural.ts +23 -0
  35. package/blocks/_foundation/server/connect-route.ts +15 -0
  36. package/blocks/_foundation/server/connect.ts +127 -0
  37. package/blocks/_foundation/server/local-key.ts +70 -0
  38. package/blocks/_foundation/server/proxy.ts +103 -3
  39. package/blocks/_foundation/server/route.ts +16 -4
  40. package/blocks/agents/README.md +14 -0
  41. package/blocks/agents/block.json +33 -1
  42. package/blocks/agents/components/agent-builder.tsx +13 -8
  43. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
  44. package/blocks/agents/components/agent-run-detail.tsx +102 -0
  45. package/blocks/agents/components/agent-runs.tsx +16 -7
  46. package/blocks/agents/components/agents-screen.tsx +184 -0
  47. package/blocks/analytics/README.md +13 -0
  48. package/blocks/analytics/block.json +45 -1
  49. package/blocks/analytics/components/analytics-screen.tsx +209 -0
  50. package/blocks/analytics/components/leaderboard.tsx +14 -7
  51. package/blocks/analytics/components/report-builder.tsx +23 -9
  52. package/blocks/analytics/components/report-detail.tsx +84 -0
  53. package/blocks/analytics/components/trend-chart.tsx +62 -0
  54. package/blocks/appointments/README.md +21 -1
  55. package/blocks/appointments/block.json +43 -2
  56. package/blocks/appointments/components/availability-editor.tsx +17 -5
  57. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  58. package/blocks/appointments/components/event-type-related.tsx +77 -0
  59. package/blocks/appointments/components/meeting-list.tsx +14 -6
  60. package/blocks/appointments/components/meetings-screen.tsx +157 -0
  61. package/blocks/appointments/components/scheduling-screen.tsx +122 -0
  62. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  63. package/blocks/block.schema.json +18 -0
  64. package/blocks/block.types.ts +26 -0
  65. package/blocks/campaigns/README.md +41 -11
  66. package/blocks/campaigns/block.json +105 -42
  67. package/blocks/campaigns/components/campaign-assets.tsx +16 -5
  68. package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
  69. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  70. package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
  71. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  72. package/blocks/campaigns/components/icp-detail.tsx +154 -0
  73. package/blocks/campaigns/components/icp-editor.tsx +15 -15
  74. package/blocks/campaigns/components/persona-detail.tsx +172 -0
  75. package/blocks/campaigns/components/persona-editor.tsx +14 -11
  76. package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
  77. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  78. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  79. package/blocks/coach/README.md +10 -0
  80. package/blocks/coach/block.json +16 -1
  81. package/blocks/coach/components/coach-screen.tsx +222 -0
  82. package/blocks/copilot/README.md +13 -0
  83. package/blocks/copilot/block.json +14 -1
  84. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  85. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  86. package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
  87. package/blocks/crm/README.md +28 -0
  88. package/blocks/crm/block.json +58 -2
  89. package/blocks/crm/components/companies-screen.tsx +345 -0
  90. package/blocks/crm/components/company-detail.tsx +8 -6
  91. package/blocks/crm/components/company-table.tsx +11 -4
  92. package/blocks/crm/components/contact-detail.tsx +6 -3
  93. package/blocks/crm/components/contact-table.tsx +10 -3
  94. package/blocks/crm/components/contacts-screen.tsx +359 -0
  95. package/blocks/crm/components/dedupe-review.tsx +7 -5
  96. package/blocks/crm/components/import-wizard.tsx +4 -2
  97. package/blocks/crm/components/notes-panel.tsx +4 -4
  98. package/blocks/crm/components/record-form.tsx +5 -3
  99. package/blocks/crm/components/task-list.tsx +4 -4
  100. package/blocks/crm/components/tasks-screen.tsx +379 -0
  101. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  102. package/blocks/crm/hooks/use-page-query.ts +7 -1
  103. package/blocks/crm/lib/crm-types.ts +12 -2
  104. package/blocks/crm/lib/screen-parts.tsx +105 -0
  105. package/blocks/deals/README.md +34 -3
  106. package/blocks/deals/block.json +45 -4
  107. package/blocks/deals/components/deal-detail.tsx +27 -13
  108. package/blocks/deals/components/deal-related.tsx +87 -0
  109. package/blocks/deals/components/deals-screen.tsx +298 -0
  110. package/blocks/deals/components/pipeline-board.tsx +4 -2
  111. package/blocks/deals/components/stage-editor.tsx +4 -4
  112. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  113. package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
  114. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  115. package/blocks/deals/lib/deals-types.ts +14 -0
  116. package/blocks/dialer/README.md +34 -2
  117. package/blocks/dialer/block.json +67 -1
  118. package/blocks/dialer/components/call-contact-button.tsx +252 -0
  119. package/blocks/dialer/components/call-log-screen.tsx +205 -0
  120. package/blocks/dialer/components/call-log.tsx +7 -3
  121. package/blocks/dialer/components/call-queue.tsx +1 -1
  122. package/blocks/dialer/components/dialer-screen.tsx +319 -0
  123. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  124. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  125. package/blocks/dialer/components/session-start-form.tsx +4 -2
  126. package/blocks/dialer/components/softphone.tsx +3 -1
  127. package/blocks/enrichment/README.md +14 -2
  128. package/blocks/enrichment/block.json +32 -4
  129. package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
  130. package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
  131. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  132. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  133. package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
  134. package/blocks/enrichment/components/waterfall-config.tsx +39 -10
  135. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  136. package/blocks/forms/README.md +12 -0
  137. package/blocks/forms/block.json +39 -17
  138. package/blocks/forms/components/form-builder.tsx +3 -1
  139. package/blocks/forms/components/form-embed.tsx +3 -1
  140. package/blocks/forms/components/forms-screen.tsx +353 -0
  141. package/blocks/forms/components/submissions-table.tsx +16 -6
  142. package/blocks/inbox/README.md +27 -3
  143. package/blocks/inbox/block.json +14 -2
  144. package/blocks/inbox/components/inbox-list.tsx +7 -3
  145. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  146. package/blocks/inbox/components/reply-composer.tsx +65 -9
  147. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  148. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  149. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  150. package/blocks/inbox/lib/inbox-types.ts +47 -1
  151. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  152. package/blocks/integrations/README.md +9 -0
  153. package/blocks/integrations/block.json +46 -1
  154. package/blocks/integrations/components/crm-detail.tsx +100 -0
  155. package/blocks/integrations/components/integration-list.tsx +9 -4
  156. package/blocks/integrations/components/integrations-screen.tsx +135 -0
  157. package/blocks/integrations/components/sync-status.tsx +5 -3
  158. package/blocks/integrations/components/webhook-detail.tsx +160 -0
  159. package/blocks/integrations/components/webhook-manager.tsx +13 -8
  160. package/blocks/integrations/lib/integrations-utils.ts +6 -1
  161. package/blocks/intent/README.md +14 -0
  162. package/blocks/intent/block.json +27 -1
  163. package/blocks/intent/components/intent-feed.tsx +5 -2
  164. package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
  165. package/blocks/intent/components/intent-screen.tsx +265 -0
  166. package/blocks/intent/components/keyword-detail.tsx +124 -0
  167. package/blocks/lifecycle/README.md +16 -0
  168. package/blocks/lifecycle/block.json +34 -7
  169. package/blocks/lifecycle/components/account-related.tsx +66 -0
  170. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  171. package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
  172. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  173. package/blocks/lists/README.md +13 -0
  174. package/blocks/lists/block.json +39 -1
  175. package/blocks/lists/components/add-to-list-button.tsx +3 -2
  176. package/blocks/lists/components/list-picker.tsx +5 -1
  177. package/blocks/lists/components/list-table.tsx +11 -5
  178. package/blocks/lists/components/lists-screen.tsx +344 -0
  179. package/blocks/lists/components/suppression-manager.tsx +6 -2
  180. package/blocks/lists/components/suppressions-screen.tsx +255 -0
  181. package/blocks/mailboxes/README.md +17 -0
  182. package/blocks/mailboxes/block.json +65 -17
  183. package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
  184. package/blocks/mailboxes/components/domain-detail.tsx +142 -0
  185. package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
  186. package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
  187. package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
  188. package/blocks/mailboxes/components/warmup-health.tsx +4 -2
  189. package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
  190. package/blocks/newsletter/README.md +13 -0
  191. package/blocks/newsletter/block.json +47 -23
  192. package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
  193. package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
  194. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  195. package/blocks/nurture/README.md +12 -0
  196. package/blocks/nurture/block.json +35 -13
  197. package/blocks/nurture/components/nurture-builder.tsx +3 -1
  198. package/blocks/nurture/components/nurture-enroll.tsx +6 -3
  199. package/blocks/nurture/components/nurture-form.tsx +23 -3
  200. package/blocks/nurture/components/nurture-screen.tsx +283 -0
  201. package/blocks/nurture/lib/nurture-types.ts +1 -1
  202. package/blocks/objects/README.md +17 -0
  203. package/blocks/objects/block.json +29 -16
  204. package/blocks/objects/components/object-not-found.tsx +3 -1
  205. package/blocks/objects/components/object-record-detail.tsx +2 -1
  206. package/blocks/objects/components/object-schema-editor.tsx +3 -1
  207. package/blocks/objects/components/object-table.tsx +13 -3
  208. package/blocks/objects/components/objects-screen.tsx +147 -0
  209. package/blocks/objects/lib/object-types.ts +1 -1
  210. package/blocks/pages/README.md +12 -0
  211. package/blocks/pages/block.json +35 -22
  212. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  213. package/blocks/pages/components/content-library.tsx +46 -11
  214. package/blocks/pages/components/page-list.tsx +6 -3
  215. package/blocks/pages/components/pages-screen.tsx +154 -0
  216. package/blocks/quotes/README.md +17 -0
  217. package/blocks/quotes/block.json +38 -9
  218. package/blocks/quotes/components/product-picker.tsx +3 -1
  219. package/blocks/quotes/components/quote-list.tsx +7 -3
  220. package/blocks/quotes/components/quote-related.tsx +123 -0
  221. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  222. package/blocks/radar/README.md +10 -0
  223. package/blocks/radar/block.json +41 -1
  224. package/blocks/radar/components/competitor-detail.tsx +117 -0
  225. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  226. package/blocks/radar/components/radar-report.tsx +4 -2
  227. package/blocks/radar/components/radar-screen.tsx +214 -0
  228. package/blocks/radar/components/report-detail.tsx +104 -0
  229. package/blocks/search/README.md +10 -0
  230. package/blocks/search/block.json +16 -1
  231. package/blocks/search/components/filter-builder.tsx +3 -3
  232. package/blocks/search/components/prospect-results.tsx +5 -3
  233. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  234. package/blocks/search/components/saved-searches.tsx +4 -1
  235. package/blocks/search/hooks/use-filter-options.ts +20 -6
  236. package/blocks/search/lib/search-types.ts +2 -0
  237. package/blocks/sequencer/README.md +49 -9
  238. package/blocks/sequencer/block.json +99 -19
  239. package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
  240. package/blocks/sequencer/components/sequence-builder.tsx +211 -46
  241. package/blocks/sequencer/components/sequence-detail.tsx +259 -0
  242. package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
  243. package/blocks/sequencer/components/sequence-list.tsx +33 -8
  244. package/blocks/sequencer/components/sequence-stats.tsx +3 -2
  245. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  246. package/blocks/sequencer/components/sequences-screen.tsx +159 -0
  247. package/blocks/sequencer/components/step-editor.tsx +3 -1
  248. package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
  249. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  250. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  251. package/blocks/social-ads/README.md +11 -0
  252. package/blocks/social-ads/block.json +101 -2
  253. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  254. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  255. package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
  256. package/blocks/social-ads/components/audience-sync.tsx +11 -7
  257. package/blocks/social-ads/components/campaign-detail.tsx +133 -0
  258. package/blocks/social-ads/components/creative-detail.tsx +150 -0
  259. package/blocks/social-ads/components/creative-gallery.tsx +15 -5
  260. package/blocks/social-ads/components/post-composer.tsx +3 -1
  261. package/blocks/social-ads/components/post-detail.tsx +160 -0
  262. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  263. package/blocks/social-ads/components/social-screen.tsx +160 -0
  264. package/blocks/visitors/README.md +10 -0
  265. package/blocks/visitors/block.json +25 -1
  266. package/blocks/visitors/components/snippet-install.tsx +3 -1
  267. package/blocks/visitors/components/visitor-company-card.tsx +2 -2
  268. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  269. package/blocks/visitors/components/visitor-feed.tsx +11 -2
  270. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  271. package/blocks/webchat/README.md +10 -0
  272. package/blocks/webchat/block.json +21 -7
  273. package/blocks/webchat/components/chat-widget-config.tsx +7 -4
  274. package/blocks/webchat/components/conversation-list.tsx +7 -3
  275. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  276. package/blocks/workbench/README.md +11 -0
  277. package/blocks/workbench/block.json +48 -1
  278. package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
  279. package/blocks/workbench/components/workbench-grid.tsx +9 -3
  280. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  281. package/blocks/workbench/components/workbench-screen.tsx +192 -0
  282. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  283. package/blocks/workflows/README.md +13 -0
  284. package/blocks/workflows/block.json +28 -9
  285. package/blocks/workflows/components/run-history.tsx +31 -9
  286. package/blocks/workflows/components/trigger-config.tsx +3 -1
  287. package/blocks/workflows/components/workflow-editor.tsx +3 -1
  288. package/blocks/workflows/components/workflow-list.tsx +13 -6
  289. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  290. package/dist/cli.js +2785 -1637
  291. package/dist/errors.d.mts +34 -0
  292. package/dist/errors.d.ts +34 -0
  293. package/dist/errors.js +46 -0
  294. package/dist/errors.js.map +1 -0
  295. package/dist/errors.mjs +21 -0
  296. package/dist/errors.mjs.map +1 -0
  297. package/dist/index.browser.js +11 -5
  298. package/dist/index.browser.js.map +1 -1
  299. package/dist/index.browser.mjs +11 -5
  300. package/dist/index.browser.mjs.map +1 -1
  301. package/dist/index.d.mts +164 -25
  302. package/dist/index.d.ts +164 -25
  303. package/dist/index.js +11 -5
  304. package/dist/index.js.map +1 -1
  305. package/dist/index.mjs +11 -5
  306. package/dist/index.mjs.map +1 -1
  307. package/dist/react.browser.js +10 -4
  308. package/dist/react.browser.js.map +1 -1
  309. package/dist/react.browser.mjs +10 -4
  310. package/dist/react.browser.mjs.map +1 -1
  311. package/dist/react.d.mts +142 -5
  312. package/dist/react.d.ts +142 -5
  313. package/dist/react.js +10 -4
  314. package/dist/react.js.map +1 -1
  315. package/dist/react.mjs +10 -4
  316. package/dist/react.mjs.map +1 -1
  317. package/dist/types/blocks/add.d.ts +11 -4
  318. package/dist/types/blocks/brand.d.ts +113 -0
  319. package/dist/types/blocks/compose.d.ts +53 -0
  320. package/dist/types/blocks/create.d.ts +25 -8
  321. package/dist/types/blocks/doctor.d.ts +6 -1
  322. package/dist/types/blocks/flows.d.ts +20 -0
  323. package/dist/types/blocks/navfile.d.ts +35 -0
  324. package/dist/types/blocks/paths.d.ts +4 -0
  325. package/dist/types/blocks/prefixes.d.ts +37 -0
  326. package/dist/types/blocks/registry.d.ts +32 -0
  327. package/dist/types/blocks/screens.d.ts +53 -0
  328. package/dist/types/cli.d.ts +2 -0
  329. package/dist/types/errors.d.ts +33 -0
  330. package/dist/types/http.d.ts +2 -18
  331. package/dist/types/version.d.ts +2 -2
  332. package/docs/agents.md +15 -4
  333. package/docs/api/README.md +2 -2
  334. package/docs/api/by-path.md +1 -0
  335. package/docs/api/desk.d.ts +98 -2
  336. package/docs/api/desk.md +123 -1
  337. package/docs/api/inbox.d.ts +10 -2
  338. package/docs/api/inbox.md +10 -2
  339. package/docs/api/seo.d.ts +19 -1
  340. package/docs/api/seo.md +24 -1
  341. package/package.json +16 -2
  342. package/stacks/6sense.json +41 -16
  343. package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
  344. package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
  345. package/stacks/apollo.json +118 -36
  346. package/stacks/calendly.json +60 -0
  347. package/stacks/clay/pages/companies.tsx.tmpl +15 -0
  348. package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
  349. package/stacks/clay.json +59 -26
  350. package/stacks/gong.json +81 -35
  351. package/stacks/hootsuite.json +99 -0
  352. package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
  353. package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
  354. package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
  355. package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
  356. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  357. package/stacks/hubspot.json +167 -66
  358. package/stacks/intercom.json +48 -20
  359. package/stacks/mailchimp.json +52 -33
  360. package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
  361. package/stacks/outreach.json +88 -25
  362. package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
  363. package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
  364. package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
  365. package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
  366. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  367. package/stacks/pipedrive.json +115 -0
  368. package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
  369. package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
  370. package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
  371. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  372. package/stacks/salesforce.json +81 -31
  373. package/stacks/stack.schema.json +6 -1
  374. package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
  375. package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
  376. package/stacks/zoominfo.json +102 -0
  377. package/styles/styles.css +1 -1
  378. package/templates/next-app/README.md.tmpl +55 -8
  379. package/templates/next-app/app/globals.css.tmpl +105 -0
  380. package/templates/next-app/app/layout.tsx.tmpl +18 -4
  381. package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
  382. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  383. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  384. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  385. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  386. package/templates/next-app/dot-gitignore.tmpl +2 -0
  387. package/templates/next-app/lib/nav.ts.tmpl +1 -1
  388. package/templates/next-app/package.json.tmpl +1 -1
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AdsScreen: the finished paid-ads page. Three tabs:
5
+ * Campaigns Meta, LinkedIn, Google and X campaigns with budget, dates and results; refresh status, retry a failed launch,
6
+ * stop and delete (AdCampaignList).
7
+ * Audiences send a list to an ad platform as a custom audience, keep it synced, and launch a campaign that targets it
8
+ * (AudienceSync). A launched campaign moves you to the Campaigns tab.
9
+ * Creatives generate, edit and organize AI ad creatives (CreativeGallery).
10
+ *
11
+ * Every campaign, audience sync and creative opens a complete detail screen (`?id=`, full page with `?view=full`):
12
+ * CampaignDetail, AudienceSyncDetail (runs, errors, launch) and CreativeDetail (edit copy, new image, gallery, delete).
13
+ *
14
+ * SDK operations: one read of getPerCampaignMetrics (30 days) for the stat strip. Composes AdCampaignList (external +
15
+ * destructive), AudienceSync (external, sends people's data to a third party) and CreativeGallery (billable): every
16
+ * spend, send or delete is confirmed by those parts. Ad accounts are connected in the graph8 app (OAuth), see the link-out card.
17
+ */
18
+ import { useState } from "react";
19
+ import type { ReactNode } from "react";
20
+ import { Target } from "lucide-react";
21
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
22
+ import { useUrlState } from "@/g8/hooks/use-url-state";
23
+ import { g8Get } from "@/g8/lib/g8-client";
24
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
25
+ import type { StatItem } from "@/g8/components/screen/stat-strip";
26
+ import { SectionCard } from "@/g8/components/screen/section-card";
27
+ import { SplitView } from "@/g8/components/screen/split-view";
28
+ import { listOf } from "../lib/social-ads-types";
29
+ import type { CampaignMetric } from "../lib/social-ads-types";
30
+ import { AdCampaignList } from "./ad-campaign-list";
31
+ import { AudienceSync } from "./audience-sync";
32
+ import { AudienceSyncDetail } from "./audience-sync-detail";
33
+ import { CampaignDetail } from "./campaign-detail";
34
+ import { CreativeDetail } from "./creative-detail";
35
+ import { CreativeGallery } from "./creative-gallery";
36
+ import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
37
+
38
+ export interface AdsScreenProps {
39
+ /** Where "Connect ad accounts" sends people. Default: graph8 Settings > Integrations. */
40
+ connectUrl?: string;
41
+ toolbarActions?: ReactNode;
42
+ className?: string;
43
+ }
44
+
45
+ /** Integrations settings on the platform's web app, resolved against the brand. */
46
+ const APP_PATH = "/studio/settings?tab=integrations";
47
+ const TABS = ["campaigns", "audiences", "creatives"] as const;
48
+ type Tab = (typeof TABS)[number];
49
+
50
+ function useStats(refreshKey: number): StatItem[] | undefined {
51
+ const q = useG8Query(async (signal) => listOf<CampaignMetric>((await g8Get("/ad-campaigns/metrics", { days: 30 }, { signal })).data, "metrics"), [refreshKey]);
52
+ if (q.error || !q.data) return undefined;
53
+ // The metrics payload is open-shaped in the contract: sum only values that are really numbers, and omit what is absent.
54
+ const rows = q.data;
55
+ const total = (k: "impressions" | "clicks" | "spend"): number | undefined => {
56
+ const nums = rows.map((r) => r[k]).filter((v): v is number => typeof v === "number");
57
+ return nums.length ? nums.reduce((s, v) => s + v, 0) : undefined;
58
+ };
59
+ const impressions = total("impressions");
60
+ const clicks = total("clicks");
61
+ const spend = total("spend");
62
+ const currencies = new Set(rows.map((r) => r.currency).filter(Boolean));
63
+ const items: StatItem[] = [];
64
+ if (spend != null) {
65
+ // Spend in several currencies cannot be added up honestly: show it only when there is one.
66
+ const cur = [...currencies][0] ?? "";
67
+ items.push({ label: "Spend (30 days)", value: currencies.size > 1 ? "Mixed currencies" : `${cur} ${spend.toLocaleString(undefined, { maximumFractionDigits: 2 })}`.trim() });
68
+ }
69
+ if (impressions != null) items.push({ label: "Impressions", value: impressions.toLocaleString() });
70
+ if (clicks != null) items.push({ label: "Clicks", value: clicks.toLocaleString() });
71
+ if (impressions && clicks != null) items.push({ label: "CTR", value: `${((clicks / impressions) * 100).toFixed(2)}%` });
72
+ return items.length ? items : undefined;
73
+ }
74
+
75
+ export function AdsScreen(props: AdsScreenProps) {
76
+ const brand = useBrand();
77
+ const connectUrl = props.connectUrl ?? brandUrl(brand, APP_PATH);
78
+ const [tabValue, setTab] = useUrlState("tab", "campaigns");
79
+ const tab: Tab = (TABS as readonly string[]).includes(tabValue) ? (tabValue as Tab) : "campaigns";
80
+ const [refreshKey, setRefreshKey] = useState(0);
81
+ const [id, setId] = useUrlState("id", "");
82
+ const [view, setView] = useUrlState("view", "");
83
+ const expanded = view === "full" && Boolean(id);
84
+ const stats = useStats(refreshKey);
85
+ const close = () => {
86
+ setId("");
87
+ setView("");
88
+ };
89
+ const bump = () => setRefreshKey((k) => k + 1);
90
+ const panel = {
91
+ onClose: close,
92
+ onExpand: expanded ? undefined : () => setView("full"),
93
+ onCollapse: expanded ? () => setView("") : undefined,
94
+ onChanged: bump,
95
+ };
96
+ const split = (list: ReactNode, detail: ReactNode) => (
97
+ <SplitView
98
+ detailOpen={Boolean(id)}
99
+ onCloseDetail={close}
100
+ expanded={expanded}
101
+ onExpandedChange={(next) => setView(next ? "full" : "")}
102
+ detailWidth="lg"
103
+ list={list}
104
+ detail={id ? detail : null}
105
+ />
106
+ );
107
+
108
+ return (
109
+ <ScreenLayout
110
+ title="Ads"
111
+ description="Sync audiences to ad platforms, launch campaigns and make creatives, with the results next to them."
112
+ icon={<Target className="size-5" />}
113
+ actions={props.toolbarActions}
114
+ tabs={[
115
+ { id: "campaigns", label: "Campaigns" },
116
+ { id: "audiences", label: "Audiences" },
117
+ { id: "creatives", label: "Creatives" },
118
+ ]}
119
+ activeTab={tab}
120
+ onTabChange={(next) => {
121
+ setTab(next);
122
+ close();
123
+ }}
124
+ stats={tab === "campaigns" ? stats : undefined}
125
+ className={props.className}
126
+ >
127
+ {tab === "campaigns" ? (
128
+ split(
129
+ <AdCampaignList refreshKey={refreshKey} onSelectCampaign={(c) => setId(String(c.id))} />,
130
+ <CampaignDetail key={id} campaignId={Number(id)} {...panel} onOpenSync={(syncId) => { setTab("audiences"); setId(String(syncId)); }} />,
131
+ )
132
+ ) : tab === "audiences" ? (
133
+ <div className="space-y-4">
134
+ <SectionCard title="Ad accounts" description={`Meta, LinkedIn, Google and X sign-in happens in ${brand.linkLabel}. Connect there, then sync audiences here.`}>
135
+ <a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">
136
+ Connect ad accounts in {brand.linkLabel}
137
+ </a>
138
+ </SectionCard>
139
+ {split(
140
+ <AudienceSync key={refreshKey} onSelectSync={(sync) => setId(String(sync.id))} onLaunched={() => { bump(); }} />,
141
+ <AudienceSyncDetail key={id} syncId={Number(id)} {...panel} onLaunched={() => { bump(); setTab("campaigns"); close(); }} />,
142
+ )}
143
+ </div>
144
+ ) : (
145
+ split(
146
+ <CreativeGallery key={refreshKey} onSelectCreative={(c) => setId(c.id)} />,
147
+ <CreativeDetail key={id} creativeId={id} {...panel} />,
148
+ )
149
+ )}
150
+ </ScreenLayout>
151
+ );
152
+ }
@@ -0,0 +1,188 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AudienceSyncDetail: the complete screen for one audience sync (a graph8 list sent to an ad platform). Finds it by id (so `?id=`
5
+ * deep links work) and shows status, cadence, last and next run, and the last error. Tabs: Overview, Runs, Errors, Launch campaign.
6
+ * Actions: sync now, pause or resume, launch a campaign that targets the audience, delete.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. listAudienceSyncs GET /audience-syncs (read)
10
+ * 2. listSyncErrors GET /audience-syncs/{id}/errors (read)
11
+ * 3. listSyncRuns GET /audience-syncs/{id}/runs (billable tier, confirmed before loading)
12
+ * 4. triggerAudienceSync POST /audience-syncs/{id}/trigger (external, confirmed)
13
+ * 5. updateAudienceSync PATCH /audience-syncs/{id} (external, confirmed; pause or resume)
14
+ * 6. launchAdCampaign POST /audience-syncs/{id}/launch (external, confirmed with the daily budget; HTTP 200 may carry status "error")
15
+ * 7. deleteAudienceSync DELETE /audience-syncs/{id} (destructive, confirmed; 404 counts as done)
16
+ */
17
+ import { useState } from "react";
18
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
19
+ import { ApiError } from "@/g8/components/api-error";
20
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
+ import { EmptyState } from "@/g8/components/empty-state";
22
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
24
+ import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
25
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
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 { formatInstant, formatMoney, launchOutcome, listOf } from "../lib/social-ads-types";
30
+ import type { AudienceSyncConfig } from "../lib/social-ads-types";
31
+ import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
32
+ import { useBrand } from "@/g8/hooks/use-brand";
33
+
34
+ export interface AudienceSyncDetailProps {
35
+ syncId: number;
36
+ onClose?: () => void;
37
+ onExpand?: () => void;
38
+ onCollapse?: () => void;
39
+ onChanged?: () => void;
40
+ /** Called after a campaign was launched. */
41
+ onLaunched?: () => void;
42
+ }
43
+
44
+ interface SyncRun {
45
+ id: number;
46
+ status?: string | null;
47
+ started_at?: string | null;
48
+ finished_at?: string | null;
49
+ members_added?: number | null;
50
+ members_removed?: number | null;
51
+ total_members?: number | null;
52
+ error_message?: string | null;
53
+ }
54
+ interface SyncError { id: number; error_message?: string; started_at?: string }
55
+
56
+ const EMPTY_LAUNCH = { name: "", objective: "", daily_budget: "", currency: "USD", destination_url: "", creative_image_url: "", headline: "", primary_text: "", start_date: "", end_date: "" };
57
+
58
+ function Runs({ syncId }: { syncId: number }) {
59
+ const [confirm, setConfirm] = useState(false);
60
+ const [runs, setRuns] = useState<SyncRun[] | null>(null);
61
+ const load = useG8Mutation(async () => { setRuns(listOf<SyncRun>((await g8Get(`/audience-syncs/${syncId}/runs`, { limit: 20 })).data, "runs")); });
62
+ return (
63
+ <SectionCard title="Runs" description="Loading past runs is a billable read, so it asks first.">
64
+ {runs == null ? <Button size="sm" variant="outline" onClick={() => setConfirm(true)}>Load runs</Button> : runs.length === 0 ? <p className="text-sm text-muted-foreground">No runs yet.</p> : (
65
+ <ul className="space-y-1 text-sm">
66
+ {runs.map((r) => (
67
+ <li key={r.id} className="flex justify-between gap-3">
68
+ <span>{r.status ?? "unknown"}{r.error_message ? `: ${r.error_message}` : ""}</span>
69
+ <span className="text-xs text-muted-foreground tabular-nums">
70
+ {[r.total_members != null ? `${r.total_members.toLocaleString()} members` : null, r.members_added != null ? `+${r.members_added}` : null, r.members_removed != null ? `-${r.members_removed}` : null, r.started_at ? formatInstant(r.started_at) : null].filter(Boolean).join(" · ")}
71
+ </span>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ )}
76
+ {load.error ? <ApiError error={load.error} onRetry={load.reset} /> : null}
77
+ <ConfirmSpendDialog open={confirm} tier="billable" title="Load sync runs" description="Listing the runs of a sync is classified billable by the API." confirmLabel="Load runs" confirming={load.loading}
78
+ onCancel={() => setConfirm(false)} onConfirm={() => void load.mutate(undefined).then(() => setConfirm(false))} />
79
+ </SectionCard>
80
+ );
81
+ }
82
+
83
+ export function AudienceSyncDetail({ syncId, onClose, onExpand, onCollapse, onChanged, onLaunched }: AudienceSyncDetailProps) {
84
+ const brand = useBrand();
85
+ const { ask, dialog, failure } = useConfirmAction();
86
+ const list = useG8Query(async (signal) => listOf<AudienceSyncConfig>((await g8Get("/audience-syncs", undefined, { signal })).data), []);
87
+ const errors = useG8Query(async (signal) => listOf<SyncError>((await g8Get(`/audience-syncs/${syncId}/errors`, { limit: 10 }, { signal })).data), [syncId]);
88
+ const [launch, setLaunch] = useState(EMPTY_LAUNCH);
89
+ const [confirmLaunch, setConfirmLaunch] = useState(false);
90
+ const [notice, setNotice] = useState<string | null>(null);
91
+ const sync = (list.data ?? []).find((s) => s.id === syncId);
92
+ const changed = () => { void list.refetch(); onChanged?.(); };
93
+ const doLaunch = useG8Mutation(async () => {
94
+ const res = await g8Post(`/audience-syncs/${syncId}/launch`, {
95
+ name: launch.name.trim(), objective: launch.objective.trim() || undefined, daily_budget: Number(launch.daily_budget), currency: launch.currency,
96
+ destination_url: launch.destination_url.trim(), creative_image_url: launch.creative_image_url.trim() || undefined, headline: launch.headline.trim() || undefined,
97
+ primary_text: launch.primary_text.trim() || undefined, start_date: launch.start_date || undefined, end_date: launch.end_date || undefined,
98
+ });
99
+ const outcome = launchOutcome(res.data);
100
+ if (!outcome.ok) throw new Error(outcome.message);
101
+ setLaunch(EMPTY_LAUNCH);
102
+ setNotice(outcome.message ?? "Campaign sent to the platform.");
103
+ onLaunched?.();
104
+ });
105
+
106
+ if (list.error) return <ApiError error={list.error} title="Could not load the sync" onRetry={() => void list.refetch()} />;
107
+ if (!list.data) return <Skeleton className="h-40 w-full" />;
108
+ if (!sync) return <EmptyState title="Sync not found" description="It may have been deleted." />;
109
+
110
+ const datesOk = !launch.start_date || !launch.end_date || launch.end_date >= launch.start_date;
111
+ const ready = launch.name.trim() && Number(launch.daily_budget) > 0 && /^https?:\/\//.test(launch.destination_url.trim()) && datesOk;
112
+ const name = sync.platform_audience_name || `List ${sync.audience_id}`;
113
+
114
+ const actions = (
115
+ <div className="flex flex-wrap gap-1">
116
+ <AskButton ask={ask} label="Sync now" tier="external" title={`Sync "${name}" to ${sync.platform} now`} confirmLabel="Sync now" disabled={!sync.is_active}
117
+ description={`Pushes the current members of the list to ${sync.platform}.`} run={() => g8Post(`/audience-syncs/${syncId}/trigger`, {})} onDone={() => { setNotice("Sync started."); changed(); }} />
118
+ <AskButton ask={ask} label={sync.is_active ? "Pause" : "Resume"} tier="external" title={`${sync.is_active ? "Pause" : "Resume"} the ${sync.platform} sync`} confirmLabel={sync.is_active ? "Pause" : "Resume"}
119
+ description={sync.is_active ? "The audience stops refreshing; people already there stay." : `Resumes refreshing the audience on ${sync.platform}.`}
120
+ run={() => g8Patch(`/audience-syncs/${syncId}`, { is_active: !sync.is_active })} onDone={changed} />
121
+ <AskButton ask={ask} label="Delete" variant="ghost" tier="destructive" title={`Delete the ${sync.platform} sync`} confirmLabel="Delete" tolerateStatus={[404]}
122
+ description={`${brand.linkLabel} stops syncing. The audience that already exists on the platform is not removed from here.`} run={() => g8Delete(`/audience-syncs/${syncId}`)} onDone={() => { changed(); onClose?.(); }} />
123
+ </div>
124
+ );
125
+
126
+ const input = (key: keyof typeof EMPTY_LAUNCH, label: string, type = "text") => (
127
+ <div className="flex flex-col gap-1" key={key}>
128
+ <Label htmlFor={`sd-${key}`}>{label}</Label>
129
+ <Input id={`sd-${key}`} type={type} value={launch[key]} onChange={(e) => setLaunch({ ...launch, [key]: e.target.value })} />
130
+ </div>
131
+ );
132
+ const tabs = [
133
+ {
134
+ id: "overview",
135
+ label: "Overview",
136
+ content: (
137
+ <div className="space-y-3">
138
+ <KeyValueList columns={2} items={[
139
+ { label: "Platform", value: sync.platform },
140
+ { label: "List", value: `${sync.audience_id}` },
141
+ { label: "Mode", value: sync.mode },
142
+ { label: "Refresh every", value: `${sync.refresh_cadence_hours} h` },
143
+ { label: "Last sync", value: sync.last_sync_at ? formatInstant(sync.last_sync_at) : "not synced yet" },
144
+ { label: "Next sync", value: sync.next_sync_at ? formatInstant(sync.next_sync_at) : "-" },
145
+ ]} />
146
+ {sync.status_detail ? <p className="text-sm text-muted-foreground">{sync.status_detail}</p> : null}
147
+ {sync.last_sync_error ? <p role="alert" className="text-sm text-error-foreground">{sync.last_sync_error}</p> : null}
148
+ {notice ? <p role="status" className="text-sm text-muted-foreground">{notice}</p> : null}
149
+ </div>
150
+ ),
151
+ },
152
+ { id: "runs", label: "Runs", content: <Runs syncId={syncId} /> },
153
+ {
154
+ id: "errors",
155
+ label: "Errors",
156
+ content: errors.loading ? <Skeleton className="h-8 w-full" /> : errors.error ? <ApiError error={errors.error} onRetry={() => void errors.refetch()} /> : (errors.data ?? []).length === 0 ? <p className="text-sm text-muted-foreground">No recent errors.</p> : (
157
+ <ul className="space-y-1 text-sm">{(errors.data ?? []).map((e) => <li key={e.id}>{e.started_at ? `${formatInstant(e.started_at)}: ` : ""}{e.error_message}</li>)}</ul>
158
+ ),
159
+ },
160
+ {
161
+ id: "launch",
162
+ label: "Launch campaign",
163
+ content: (
164
+ <SectionCard title="Launch a campaign on this audience" description={`Creates the campaign on ${sync.platform}. Spend is billed by the platform.`}>
165
+ <form className="grid gap-2 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (ready) setConfirmLaunch(true); }}>
166
+ {input("name", "Campaign name")}{input("objective", "Objective")}{input("daily_budget", "Daily budget", "number")}{input("destination_url", "Landing page URL")}
167
+ {input("creative_image_url", "Image URL (required on Google)")}{input("headline", "Headline")}{input("primary_text", "Primary text")}
168
+ <div className="flex flex-col gap-1"><Label htmlFor="sd-currency">Currency</Label>
169
+ <select id="sd-currency" className="h-9 rounded-md border bg-background px-2" value={launch.currency} onChange={(e) => setLaunch({ ...launch, currency: e.target.value })}>{["USD", "EUR", "GBP", "CAD", "AUD"].map((c) => <option key={c} value={c}>{c}</option>)}</select></div>
170
+ {input("start_date", "Start date", "date")}{input("end_date", "End date", "date")}
171
+ <div><Button type="submit" size="sm" disabled={!ready || doLaunch.loading}>Review launch</Button>{!datesOk ? <p role="alert" className="mt-1 text-xs text-error-foreground">The end date is before the start date.</p> : null}</div>
172
+ </form>
173
+ {doLaunch.error ? <ApiError error={doLaunch.error} title="Launch failed" /> : null}
174
+ <ConfirmSpendDialog open={confirmLaunch} tier="external" title={`Launch a campaign on ${sync.platform}`} confirmLabel="Launch" confirming={doLaunch.loading}
175
+ description={`Creates the campaign on ${sync.platform}, targeting the synced audience, at ${formatMoney(Number(launch.daily_budget), launch.currency)} per day. Once the platform approves it, it spends from your ad account; ${brand.linkLabel} credits are not used for that spend.`}
176
+ onCancel={() => setConfirmLaunch(false)} onConfirm={() => void doLaunch.mutate(undefined).then(() => setConfirmLaunch(false))} />
177
+ </SectionCard>
178
+ ),
179
+ },
180
+ ];
181
+ return (
182
+ <>
183
+ <DetailPanel title={name} subtitle={`on ${sync.platform}`} badges={<StatusPill tone={sync.is_active ? "positive" : "neutral"}>{sync.is_active ? sync.status ?? "active" : "paused"}</StatusPill>} actions={actions} tabs={tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
184
+ {failure ? <ApiError error={failure} title="That did not work" /> : null}
185
+ <ConfirmSpendDialog {...dialog} />
186
+ </>
187
+ );
188
+ }
@@ -32,10 +32,13 @@ import { ListPicker } from "@/g8/lists/components/list-picker";
32
32
  import type { ListSummary } from "@/g8/lists/components/list-picker";
33
33
  import { AD_PLATFORMS, formatInstant, formatMoney, launchOutcome, listOf } from "../lib/social-ads-types";
34
34
  import type { AudienceSyncConfig } from "../lib/social-ads-types";
35
+ import { DEFAULT_BRAND, useBrand } from "@/g8/hooks/use-brand";
35
36
 
36
37
  export interface AudienceSyncProps {
37
38
  /** Called after a campaign was launched, for example to refresh an AdCampaignList. */
38
39
  onLaunched?: () => void;
40
+ /** Called when a person opens a sync (show an AudienceSyncDetail). */
41
+ onSelectSync?: (sync: AudienceSyncConfig) => void;
39
42
  className?: string;
40
43
  }
41
44
 
@@ -46,7 +49,8 @@ type Pending =
46
49
 
47
50
  const listSize = (l: ListSummary) => l.total ?? l.contact_count ?? l.company_count ?? undefined;
48
51
 
49
- export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
52
+ export function AudienceSync({ onLaunched, onSelectSync, className }: AudienceSyncProps) {
53
+ const brand = useBrand();
50
54
  const [list, setList] = useState<ListSummary | null>(null);
51
55
  const [platform, setPlatform] = useState<string>("meta");
52
56
  const [mode, setMode] = useState("mirror");
@@ -87,7 +91,7 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
87
91
 
88
92
  const datesOk = !launch.start_date || !launch.end_date || launch.end_date >= launch.start_date; // YYYY-MM-DD compares as text
89
93
  const readyToLaunch = launch.name.trim() && Number(launch.daily_budget) > 0 && /^https?:\/\//.test(launch.destination_url.trim()) && datesOk;
90
- const meta = describe(pending, platform, formatMoney(Number(launch.daily_budget), launch.currency));
94
+ const meta = describe(pending, platform, formatMoney(Number(launch.daily_budget), launch.currency), brand.linkLabel);
91
95
  return (
92
96
  <div className={`flex flex-col gap-5 ${className ?? ""}`}>
93
97
  <section className="flex flex-col gap-2 rounded-md border p-3" aria-label="New audience sync">
@@ -104,12 +108,12 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
104
108
  {notice ? <p role="status" className="text-sm text-muted-foreground">{notice}</p> : null}
105
109
  {syncs.error ? <ApiError error={syncs.error} title="Could not load syncs" onRetry={() => void syncs.refetch()} /> : null}
106
110
  {syncs.loading && !syncs.data ? <Skeleton className="h-20 w-full" /> : null}
107
- {!syncs.loading && !syncs.error && (syncs.data ?? []).length === 0 ? <EmptyState title="No audience syncs yet" description="Pick a list above and the platform to send it to. The ad account must be connected in graph8 first." /> : null}
111
+ {!syncs.loading && !syncs.error && (syncs.data ?? []).length === 0 ? <EmptyState title="No audience syncs yet" description={`Pick a list above and the platform to send it to. The ad account must be connected in ${brand.linkLabel} first.`} /> : null}
108
112
  <ul className="flex flex-col gap-2">
109
113
  {(syncs.data ?? []).map((s) => (
110
114
  <li key={s.id} className="flex flex-col gap-2 rounded-md border p-3" data-testid="sync" aria-busy={busy === s.id}>
111
115
  <div className="flex flex-wrap items-center justify-between gap-2">
112
- <div><p className="text-sm font-medium">{s.platform_audience_name || `List ${s.audience_id}`} on {s.platform}</p>
116
+ <div>{onSelectSync ? <button type="button" className="text-left text-sm font-medium hover:underline" onClick={() => onSelectSync(s)}>{s.platform_audience_name || `List ${s.audience_id}`} on {s.platform}</button> : <p className="text-sm font-medium">{s.platform_audience_name || `List ${s.audience_id}`} on {s.platform}</p>}
113
117
  <p className="text-xs text-muted-foreground">{s.is_active ? s.status ?? "active" : "paused"} · {s.last_sync_at ? `last synced ${formatInstant(s.last_sync_at)}` : "not synced yet"}{s.last_sync_error ? ` · ${s.last_sync_error}` : ""}</p></div>
114
118
  <div className="flex flex-wrap gap-1">
115
119
  <Button size="sm" variant="outline" loading={busy === s.id} disabled={busy != null || !s.is_active} onClick={() => setPending({ kind: "trigger", sync: s })}>Sync now</Button>
@@ -139,12 +143,12 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
139
143
  );
140
144
  }
141
145
 
142
- function describe(p: Pending | null, platform: string, budget: string): { tier: ConfirmTier; title: string; text: string; label: string; recipients?: number; affected?: number } {
146
+ function describe(p: Pending | null, platform: string, budget: string, brandLabel: string = DEFAULT_BRAND.linkLabel): { tier: ConfirmTier; title: string; text: string; label: string; recipients?: number; affected?: number } {
143
147
  if (!p) return { tier: "external", title: "", text: "", label: "Confirm" };
144
148
  if (p.kind === "create") return { tier: "external", title: `Send "${p.list.title}" to ${platform}`, label: "Create sync", recipients: listSize(p.list), text: `Sends the contacts of this list to ${platform} as a custom audience (matched on hashed identifiers) and keeps it refreshed. Anyone matched becomes targetable by your ads there.` };
145
149
  const s = p.sync;
146
150
  if (p.kind === "trigger") return { tier: "external", title: `Sync "${s.platform_audience_name || s.audience_id}" to ${s.platform} now`, label: "Sync now", text: `Pushes the current members of the list to ${s.platform}.` };
147
151
  if (p.kind === "toggle") return { tier: "external", title: `${s.is_active ? "Pause" : "Resume"} the ${s.platform} sync`, label: s.is_active ? "Pause" : "Resume", text: s.is_active ? "The audience stops refreshing; people already there stay." : `Resumes refreshing the audience on ${s.platform}.` };
148
- if (p.kind === "delete") return { tier: "destructive", title: `Delete the ${s.platform} sync`, label: "Delete", text: "graph8 stops syncing. The audience that already exists on the platform is not removed from here." };
149
- return { tier: "external", title: `Launch a campaign on ${s.platform}`, label: "Launch", text: `Creates the campaign on ${s.platform}, targeting the synced audience, at ${budget} per day. Once the platform approves it, it spends from your ad account; graph8 credits are not used for that spend.` };
152
+ if (p.kind === "delete") return { tier: "destructive", title: `Delete the ${s.platform} sync`, label: "Delete", text: `${brandLabel} stops syncing. The audience that already exists on the platform is not removed from here.` };
153
+ return { tier: "external", title: `Launch a campaign on ${s.platform}`, label: "Launch", text: `Creates the campaign on ${s.platform}, targeting the synced audience, at ${budget} per day. Once the platform approves it, it spends from your ad account; ${brandLabel} credits are not used for that spend.` };
150
154
  }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CampaignDetail: the complete screen for one ad campaign. Finds the campaign by id (so `?id=` deep links work) and shows
5
+ * every field plus its results for a window, and the actions that fit it: refresh its review status, retry a failed launch,
6
+ * stop and delete. Date-only values are shown in UTC; spend is in the campaign's own currency.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. listAdCampaigns GET /ad-campaigns (read)
10
+ * 2. getPerCampaignMetrics GET /ad-campaigns/metrics?days (read)
11
+ * 3. refreshCampaignStatus POST /ad-campaigns/{id}/status-refresh (external, confirmed)
12
+ * 4. retryCampaignLaunch POST /ad-campaigns/{id}/retry (external, confirmed; HTTP 200 may carry status "error")
13
+ * 5. deleteAdCampaign DELETE /ad-campaigns/{id} (destructive, confirmed; 502 = still live on the platform)
14
+ */
15
+ import { useState } from "react";
16
+ import { Skeleton } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { EmptyState } from "@/g8/components/empty-state";
19
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
20
+ import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
21
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
22
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
23
+ import { SectionCard } from "@/g8/components/screen/section-card";
24
+ import { StatusPill } from "@/g8/components/screen/status-pill";
25
+ import { formatDateOnly, formatInstant, formatMoney, launchOutcome, listOf, metricsFor } from "../lib/social-ads-types";
26
+ import type { AdCampaign, CampaignMetric } from "../lib/social-ads-types";
27
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
28
+ import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
29
+ import { useBrand } from "@/g8/hooks/use-brand";
30
+
31
+ export interface CampaignDetailProps {
32
+ campaignId: number;
33
+ onClose?: () => void;
34
+ onExpand?: () => void;
35
+ onCollapse?: () => void;
36
+ onChanged?: () => void;
37
+ /** Open the audience sync this campaign targets. */
38
+ onOpenSync?: (syncId: number) => void;
39
+ }
40
+
41
+ const tone = (s: string) => (["active", "approved", "running"].includes(s) ? "positive" : s === "failed" || s === "rejected" ? "negative" : s === "paused" ? "neutral" : "info") as "positive" | "negative" | "neutral" | "info";
42
+
43
+ export function CampaignDetail({ campaignId, onClose, onExpand, onCollapse, onChanged, onOpenSync }: CampaignDetailProps) {
44
+ const brand = useBrand();
45
+ const { ask, dialog, failure } = useConfirmAction();
46
+ const [days, setDays] = useState(30);
47
+ const list = useG8Query(async (signal) => listOf<AdCampaign>((await g8Get("/ad-campaigns", undefined, { signal })).data), []);
48
+ const metrics = useG8Query(async (signal) => listOf<CampaignMetric>((await g8Get("/ad-campaigns/metrics", { days }, { signal })).data, "metrics"), [days]);
49
+ const campaign = (list.data ?? []).find((c) => c.id === campaignId);
50
+ const reload = () => {
51
+ void list.refetch();
52
+ void metrics.refetch();
53
+ onChanged?.();
54
+ };
55
+
56
+ if (list.error) return <ApiError error={list.error} title="Could not load the campaign" onRetry={() => void list.refetch()} />;
57
+ if (!list.data) return <Skeleton className="h-40 w-full" />;
58
+ if (!campaign) return <EmptyState title="Campaign not found" description="It may have been deleted." />;
59
+ const m = metricsFor(campaign, metrics.data ?? []);
60
+
61
+ const actions = (
62
+ <div className="flex flex-wrap gap-1">
63
+ <AskButton ask={ask} label="Refresh status" tier="external" title={`Refresh "${campaign.name}"`} confirmLabel="Refresh"
64
+ description={`Asks ${campaign.platform} for this campaign's current review state.`} run={() => g8Post(`/ad-campaigns/${campaign.id}/status-refresh`, {})} onDone={reload} />
65
+ {campaign.status === "failed" ? (
66
+ <AskButton ask={ask} label="Retry launch" tier="external" title={`Retry launching "${campaign.name}"`} confirmLabel="Retry launch"
67
+ description={`Sends the campaign to ${campaign.platform} again. It may start spending on that platform once approved.`}
68
+ run={async () => {
69
+ const outcome = launchOutcome((await g8Post(`/ad-campaigns/${campaign.id}/retry`, {})).data);
70
+ if (!outcome.ok) throw new Error(outcome.message);
71
+ }}
72
+ onDone={reload} />
73
+ ) : null}
74
+ <AskButton ask={ask} label="Stop and delete" variant="ghost" tier="destructive" title={`Stop and delete "${campaign.name}"`} confirmLabel="Stop and delete"
75
+ description={`Stops the campaign on ${campaign.platform} first, then removes it from ${brand.linkLabel} together with its history. This cannot be undone. If ${campaign.platform} refuses to stop it, the campaign stays live and keeps spending.`}
76
+ tolerateStatus={[404]}
77
+ mapError={(err) => ((err as { status?: number })?.status === 502 ? new Error(`${campaign.platform} refused to stop "${campaign.name}". It is STILL LIVE and may keep spending: stop it in the platform's own ads manager.`) : err)}
78
+ run={() => g8Delete(`/ad-campaigns/${campaign.id}`)} onDone={() => { reload(); onClose?.(); }} />
79
+ </div>
80
+ );
81
+
82
+ const tabs = [
83
+ {
84
+ id: "overview",
85
+ label: "Overview",
86
+ content: (
87
+ <div className="space-y-4">
88
+ <KeyValueList columns={2} items={[
89
+ { label: "Platform", value: campaign.platform },
90
+ { label: "Objective", value: campaign.objective ?? "-" },
91
+ { label: "Daily budget", value: formatMoney(campaign.daily_budget, campaign.currency) },
92
+ { label: "Ads", value: campaign.ad_count ?? "-" },
93
+ { label: "Starts", value: formatDateOnly(campaign.start_date) || "-" },
94
+ { label: "Ends", value: formatDateOnly(campaign.end_date) || "-" },
95
+ { label: "Launched", value: campaign.launched_at ? formatInstant(campaign.launched_at) : "-" },
96
+ { label: "Platform campaign id", value: campaign.platform_campaign_id ?? "-" },
97
+ ]} />
98
+ {onOpenSync ? <button type="button" className="text-sm underline" onClick={() => onOpenSync(campaign.sync_config_id)}>Open its audience sync</button> : null}
99
+ </div>
100
+ ),
101
+ },
102
+ {
103
+ id: "results",
104
+ label: "Results",
105
+ content: (
106
+ <SectionCard title="Results" description={`Last ${days} days`} actions={
107
+ <select aria-label="Results window" className="h-8 rounded-md border bg-background px-2 text-sm" value={days} onChange={(e) => setDays(Number(e.target.value))}>
108
+ {[7, 30, 90].map((d) => <option key={d} value={d}>Last {d} days</option>)}
109
+ </select>
110
+ }>
111
+ {metrics.error ? <p className="text-sm text-muted-foreground">Results are unavailable right now.</p> : m ? (
112
+ <KeyValueList columns={2} items={[
113
+ { label: "Spend", value: formatMoney(m.spend, m.currency ?? campaign.currency) },
114
+ { label: "Impressions", value: m.impressions.toLocaleString() },
115
+ { label: "Clicks", value: m.clicks.toLocaleString() },
116
+ { label: "Conversions", value: m.conversions != null ? m.conversions.toLocaleString() : "-" },
117
+ { label: "CTR", value: m.impressions ? `${((m.clicks / m.impressions) * 100).toFixed(2)}%` : "-" },
118
+ { label: "CPC", value: m.clicks ? formatMoney(m.spend / m.clicks, m.currency ?? campaign.currency) : "-" },
119
+ ]} />
120
+ ) : <p className="text-sm text-muted-foreground">No results yet for this window.</p>}
121
+ </SectionCard>
122
+ ),
123
+ },
124
+ ];
125
+
126
+ return (
127
+ <>
128
+ <DetailPanel title={campaign.name} subtitle={campaign.platform} badges={<StatusPill tone={tone(campaign.status)}>{campaign.status}</StatusPill>} actions={actions} tabs={tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
129
+ {failure ? <ApiError error={failure} title="That did not work" /> : null}
130
+ <ConfirmSpendDialog {...dialog} />
131
+ </>
132
+ );
133
+ }