@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
@@ -27,6 +27,7 @@ import { useObjectSchema } from "../hooks/use-object-schema";
27
27
  import { OBJECT_ICONS, isObjectsApiMissing, isValidSlug, slugify } from "../lib/object-types";
28
28
  import type { ObjectAttribute, ObjectDefinition } from "../lib/object-types";
29
29
  import { AttributeFormDialog } from "./attribute-form-dialog";
30
+ import { useBrand } from "@/g8/hooks/use-brand";
30
31
 
31
32
  export interface ObjectSchemaEditorProps {
32
33
  /** Object to open first. */
@@ -93,6 +94,7 @@ function ObjectDetailsForm({ object, attributes, onSaved }: { object: ObjectDefi
93
94
  }
94
95
 
95
96
  export function ObjectSchemaEditor({ initialObjectSlug, onSelectObject }: ObjectSchemaEditorProps) {
97
+ const brand = useBrand();
96
98
  const [selected, setSelected] = useState<string | undefined>(initialObjectSlug);
97
99
  const [editing, setEditing] = useState<ObjectAttribute | "new" | null>(null);
98
100
  const [pending, setPending] = useState<Pending | null>(null);
@@ -113,7 +115,7 @@ export function ObjectSchemaEditor({ initialObjectSlug, onSelectObject }: Object
113
115
 
114
116
  if (objects.loading && !objects.data) return <div className="grid gap-2" aria-busy="true"><Skeleton className="h-8 w-48" /><Skeleton className="h-40 w-full" /></div>;
115
117
  if (isObjectsApiMissing(objects.error)) {
116
- return <EmptyState title="Custom objects are unavailable" description="The graph8 API at this base URL does not serve custom objects. Check G8_API_URL and that it points at a current graph8 backend, then reload." />;
118
+ return <EmptyState title="Custom objects are unavailable" description={`The ${brand.linkLabel} API at this base URL does not serve custom objects. Check G8_API_URL and that it points at a current ${brand.linkLabel} backend, then reload.`} />;
117
119
  }
118
120
  if (objects.error) return <ApiError error={objects.error} title="Could not load objects" onRetry={() => void objects.refetch()} />;
119
121
 
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { useEffect, useState } from "react";
16
+ import type { ReactNode } from "react";
16
17
  import { Button, Input, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
17
18
  import { ApiError } from "@/g8/components/api-error";
18
19
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
@@ -20,6 +21,8 @@ import { EmptyState } from "@/g8/components/empty-state";
20
21
  import { g8Delete, g8Get } from "@/g8/lib/g8-client";
21
22
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
22
23
  import { usePaginated } from "@/g8/hooks/use-paginated";
24
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
25
+ import { RecordLink } from "@/g8/components/screen/record-link";
23
26
  import { useObjectSchema } from "../hooks/use-object-schema";
24
27
  import { formatValue, isObjectsApiMissing, recordTitle } from "../lib/object-types";
25
28
  import { ObjectNotFound } from "./object-not-found";
@@ -27,6 +30,8 @@ import type { ObjectRecord } from "../lib/object-types";
27
30
  import { RecordFormDialog } from "./record-form-dialog";
28
31
 
29
32
  export interface ObjectTableProps {
33
+ /** 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. */
34
+ linkParam?: string;
30
35
  objectSlug: string;
31
36
  /** Rows per request, 1-200. Default 50. */
32
37
  pageSize?: number;
@@ -36,7 +41,7 @@ export interface ObjectTableProps {
36
41
  maxColumns?: number;
37
42
  }
38
43
 
39
- export function ObjectTable({ objectSlug, pageSize = 50, onSelectRecord, maxColumns = 6 }: ObjectTableProps) {
44
+ export function ObjectTable({ linkParam, objectSlug, pageSize = 50, onSelectRecord, maxColumns = 6 }: ObjectTableProps) {
40
45
  const schema = useObjectSchema(objectSlug);
41
46
  const slug = encodeURIComponent(objectSlug);
42
47
  const records = usePaginated<ObjectRecord>(
@@ -68,6 +73,11 @@ export function ObjectTable({ objectSlug, pageSize = 50, onSelectRecord, maxColu
68
73
  return <ApiError error={schema.error ?? records.error} title="Could not load records" onRetry={() => { void schema.refetch(); void records.reload(); }} />;
69
74
  }
70
75
 
76
+ const cellText = (c: (typeof columns)[number], i: number, record: ObjectRecord): ReactNode => {
77
+ const text = formatValue(c, record.values?.[c.slug]) || (i === 0 ? recordTitle(schema.definition, schema.attributes, record) : "");
78
+ return i === 0 && onSelectRecord && text ? <RecordLink param={linkParam} id={record.id} onOpen={() => onSelectRecord(record)}>{text}</RecordLink> : text;
79
+ };
80
+
71
81
  return (
72
82
  <div className="grid gap-3">
73
83
  <div className="flex items-center justify-between gap-2">
@@ -92,9 +102,9 @@ export function ObjectTable({ objectSlug, pageSize = 50, onSelectRecord, maxColu
92
102
  </TableHeader>
93
103
  <TableBody>
94
104
  {rows.map((record) => (
95
- <TableRow key={record.id} className={onSelectRecord ? "cursor-pointer" : undefined} onClick={() => onSelectRecord?.(record)}>
105
+ <TableRow key={record.id} className={onSelectRecord ? "cursor-pointer" : undefined} {...(onSelectRecord ? rowActivationProps(() => onSelectRecord(record)) : {})}>
96
106
  {columns.map((c, i) => (
97
- <TableCell key={c.slug}>{formatValue(c, record.values?.[c.slug]) || (i === 0 ? recordTitle(schema.definition, schema.attributes, record) : "")}</TableCell>
107
+ <TableCell key={c.slug}>{cellText(c, i, record)}</TableCell>
98
108
  ))}
99
109
  <TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
100
110
  <Button variant="ghost" size="sm" onClick={() => setForm({ record })}>Edit</Button>
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ObjectsScreen: the finished custom-objects workspace (HubSpot custom objects / Salesforce objects). Pick an object, work its
5
+ * records, open one for its values, owners, relationships and history, and define new objects and fields. No props are required.
6
+ *
7
+ * Composes: ScreenLayout, StatStrip, SplitView, FormSheet (screen kit) with ObjectTable, ObjectRecordDetail and
8
+ * ObjectSchemaEditor (this block). Standard objects (contacts, companies, deals) appear next to your own.
9
+ *
10
+ * Flows: switch between objects (deep link ?object=) - search the loaded rows, load more - create, edit and archive a record
11
+ * - open a record (deep link ?id=, full page with &view=full) to see values, owners, relationships and changes, edit, archive or restore it - define a
12
+ * new object, add, edit and archive fields, rename an object ("Define objects", needs an Admin key with objects:manage).
13
+ *
14
+ * SDK operations: none of its own. listObjects (picker, stats) is `read`; everything else runs inside the parts it composes,
15
+ * and every archive (record, field, object) goes through ConfirmSpendDialog. A 404 on the list call means the backend does not
16
+ * serve custom objects; the screen says so instead of showing an empty picker.
17
+ */
18
+
19
+ import { useState } from "react";
20
+ import type { ReactNode } from "react";
21
+ import { Button, Skeleton } from "@graph8/sdk/ui";
22
+ import { Box, Maximize2, Minimize2, Settings2 } from "lucide-react";
23
+ import { ApiError } from "@/g8/components/api-error";
24
+ import { EmptyState } from "@/g8/components/empty-state";
25
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
26
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
27
+ import { SplitView } from "@/g8/components/screen/split-view";
28
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
29
+ import { useUrlState } from "@/g8/hooks/use-url-state";
30
+ import { g8Get } from "@/g8/lib/g8-client";
31
+ import { useObjectSchema } from "../hooks/use-object-schema";
32
+ import { isObjectsApiMissing } from "../lib/object-types";
33
+ import type { ObjectDefinition } from "../lib/object-types";
34
+ import { ObjectNotFound } from "./object-not-found";
35
+ import { ObjectRecordDetail } from "./object-record-detail";
36
+ import { ObjectSchemaEditor } from "./object-schema-editor";
37
+ import { ObjectTable } from "./object-table";
38
+
39
+ export interface ObjectsScreenProps {
40
+ /** Object to open when the URL names none. Default: the first non-standard object, else the first one. */
41
+ initialObjectSlug?: string;
42
+ /** Show "Define objects" (the schema editor). Default true; it needs an Admin key behind the proxy. */
43
+ allowSchemaEditing?: boolean;
44
+ /** Extra buttons in the page header. */
45
+ toolbarActions?: ReactNode;
46
+ className?: string;
47
+ }
48
+
49
+ export function ObjectsScreen({ initialObjectSlug, allowSchemaEditing = true, toolbarActions, className }: ObjectsScreenProps) {
50
+ const [objectParam, setObjectParam] = useUrlState("object");
51
+ const [recordId, setRecordIdRaw] = useUrlState("id");
52
+ const [view, setView] = useUrlState("view");
53
+ const full = view === "full" && Boolean(recordId);
54
+ const setRecordId = (v: string | null): void => { if (v == null) setView(null); setRecordIdRaw(v); };
55
+ const [schemaOpen, setSchemaOpen] = useState(false);
56
+ const [tableKey, setTableKey] = useState(0);
57
+ const objects = useG8Query((signal) => g8Get<ObjectDefinition[]>("/objects", {}, { signal }).then((r) => r.data ?? []), []);
58
+ const all = (objects.data ?? []).filter((o) => !o.is_archived);
59
+ const slug = objectParam || initialObjectSlug || (all.find((o) => !o.is_system) ?? all[0])?.slug || "";
60
+ const current = all.find((o) => o.slug === slug);
61
+ const schema = useObjectSchema(slug || undefined);
62
+
63
+ const closeSchema = (open: boolean): void => {
64
+ setSchemaOpen(open);
65
+ if (open) return;
66
+ void objects.refetch();
67
+ void schema.refetch();
68
+ setTableKey((k) => k + 1);
69
+ };
70
+ const pick = (next: string) => { setObjectParam(next); setRecordId(null); };
71
+
72
+ if (isObjectsApiMissing(objects.error)) return <ObjectNotFound what="object" error={objects.error} />;
73
+
74
+ return (
75
+ <ScreenLayout
76
+ className={className}
77
+ title={current?.plural_noun ?? "Objects"}
78
+ description="Records of any type you define, next to your standard contacts, companies and deals."
79
+ icon={<Box className="size-5" aria-hidden />}
80
+ actions={
81
+ <>
82
+ {toolbarActions}
83
+ {allowSchemaEditing ? <Button variant="outline" size="sm" onClick={() => setSchemaOpen(true)}><Settings2 className="size-4" aria-hidden />Define objects</Button> : null}
84
+ </>
85
+ }
86
+ stats={[
87
+ { label: "Objects", value: objects.loading && !objects.data ? "-" : all.length, hint: `${all.filter((o) => !o.is_system).length} custom` },
88
+ { label: "Fields on this object", value: slug && !schema.loading ? schema.attributes.length : "-" },
89
+ ]}
90
+ >
91
+ {objects.error ? <ApiError error={objects.error} title="Could not load objects" onRetry={() => void objects.refetch()} /> : null}
92
+ {objects.loading && !objects.data ? <Skeleton className="h-9 w-full" /> : null}
93
+
94
+ {all.length > 0 ? (
95
+ <div className="mb-3 flex flex-wrap gap-2" role="group" aria-label="Objects">
96
+ {all.map((o) => (
97
+ <Button key={o.slug} variant={o.slug === slug ? "default" : "outline"} size="sm" aria-pressed={o.slug === slug} onClick={() => pick(o.slug)}>
98
+ {o.plural_noun}{o.is_system ? <span className="ml-1 text-xs opacity-70">standard</span> : null}
99
+ </Button>
100
+ ))}
101
+ </div>
102
+ ) : null}
103
+
104
+ {!objects.loading && !objects.error && all.length === 0 ? (
105
+ <EmptyState
106
+ title="No objects yet"
107
+ description="Define an object such as an invoice or a shipment, give it fields, then add records."
108
+ action={allowSchemaEditing ? <Button onClick={() => setSchemaOpen(true)}>Define your first object</Button> : undefined}
109
+ />
110
+ ) : null}
111
+
112
+ {slug ? (
113
+ <SplitView
114
+ detailOpen={Boolean(recordId)}
115
+ expanded={full}
116
+ onExpandedChange={(e) => setView(e ? "full" : null)}
117
+ onCloseDetail={() => setRecordId(null)}
118
+ detailWidth="lg"
119
+ list={<ObjectTable linkParam="id" key={`${slug}:${tableKey}`} objectSlug={slug} onSelectRecord={(r) => setRecordId(r.id)} />}
120
+ detail={recordId ? (
121
+ <div className="flex flex-col gap-2">
122
+ <div className="flex justify-end">
123
+ <Button variant="ghost" size="sm" onClick={() => setView(full ? null : "full")}>
124
+ {full ? <Minimize2 className="size-4" aria-hidden /> : <Maximize2 className="size-4" aria-hidden />}{full ? "Back to split view" : "Open full page"}
125
+ </Button>
126
+ </div>
127
+ <ObjectRecordDetail
128
+ key={`${slug}:${recordId}`}
129
+ objectSlug={slug}
130
+ recordId={recordId}
131
+ onBack={() => setRecordId(null)}
132
+ onArchived={() => { setRecordId(null); setTableKey((k) => k + 1); }}
133
+ />
134
+ </div>
135
+ ) : null}
136
+ />
137
+ ) : null}
138
+
139
+ <FormSheet open={schemaOpen} onOpenChange={closeSchema} title="Define objects" description="Create objects, add fields, rename or archive them. Needs an Admin key.">
140
+ <ObjectSchemaEditor
141
+ initialObjectSlug={slug || undefined}
142
+ onSelectObject={pick}
143
+ />
144
+ </FormSheet>
145
+ </ScreenLayout>
146
+ );
147
+ }
@@ -8,7 +8,7 @@
8
8
  * an attribute definition into the right input, display text and API value.
9
9
  */
10
10
 
11
- import { G8Error } from "@graph8/sdk";
11
+ import { G8Error } from "@graph8/sdk/errors";
12
12
 
13
13
  export type AttributeType =
14
14
  | "text" | "number" | "currency" | "date" | "timestamp" | "checkbox" | "select" | "status"
@@ -3,8 +3,20 @@
3
3
  Editable React screens for the marketing-site side of a product: landing pages (list, section editor, publish),
4
4
  a content library, the brand kit and SEO audits. Install with `npx @graph8/sdk add pages`.
5
5
 
6
+ ## Screens
7
+
8
+ `PagesScreen` (`components/pages-screen.tsx`) is the finished page: landing pages with the editor beside the list, plus content, brand kit and SEO tabs.
9
+
10
+ ```tsx
11
+ import { PagesScreen } from "@/g8/pages/components/pages-screen";
12
+ export default function Page() { return <PagesScreen />; }
13
+ ```
14
+
15
+ Flows: list and filter landing pages, create blank (free) or with AI (confirmed spend), open one (deep link `?id=`) to edit sections, preview, restore a version, publish or unpublish (confirmed) and delete (confirmed); manage the content library; re-scan the brand kit; run SEO audits. Needs the proxy prefixes listed under Prerequisites. Optional props: `defaultDomain`, `renderDetailActions`, `extraDetailTabs`, `toolbarActions`.
16
+
6
17
  | Component | What it is |
7
18
  |---|---|
19
+ | `PagesScreen` | The finished page: landing pages with the editor beside them, content, brand kit and SEO tabs. |
8
20
  | `PageList` | Landing pages with a status filter; create blank or with AI; publish, unpublish, delete. |
9
21
  | `PageEditor` | Lite editor: page details, one card per section (its text fields), preview, version restore, publish. Not a WYSIWYG canvas. |
10
22
  | `ContentLibrary` | Content inventory (blogs, website pages): filters, add by URL, edit sheet, SEO enrichment, delete. |
@@ -14,37 +14,49 @@
14
14
  "foundation"
15
15
  ],
16
16
  "operations": [
17
- "list_landing_pages_landing_pages_get",
17
+ "create_content_page_content_pages_post",
18
18
  "create_landing_page_landing_pages_post",
19
- "publish_landing_page_landing_pages__landing_page_id__publish_post",
20
- "unpublish_landing_page_landing_pages__landing_page_id__unpublish_post",
21
- "delete_landing_page_landing_pages__landing_page_id__delete",
22
- "get_landing_page_landing_pages__landing_page_id__get",
23
- "update_landing_page_landing_pages__landing_page_id__patch",
24
- "update_landing_page_section_landing_pages__landing_page_id__sections__section_idx__patch",
25
19
  "create_landing_page_preview_link_landing_pages__landing_page_id__preview_get",
26
- "list_landing_page_versions_landing_pages__landing_page_id__versions_get",
27
- "restore_landing_page_version_landing_pages__landing_page_id__versions__version_num__restore_post",
28
- "list_content_pages_content_pages_get",
29
- "create_content_page_content_pages_post",
30
- "get_content_page_content_pages__page_id__get",
31
- "update_content_page_content_pages__page_id__patch",
32
- "enrich_content_page_content_pages__page_id__enrich_seo_post",
33
20
  "delete_content_page_content_pages__page_id__delete",
21
+ "delete_landing_page_landing_pages__landing_page_id__delete",
22
+ "enrich_content_page_content_pages__page_id__enrich_seo_post",
34
23
  "get_brand_kit_overview_brand_kit_get",
35
- "trigger_brand_extraction_brand_kit_extract_post",
36
- "list_archetypes_brand_kit_archetypes_get",
24
+ "get_content_page_content_pages__page_id__get",
25
+ "get_landing_page_landing_pages__landing_page_id__get",
37
26
  "get_layout_selections_brand_kit_layout_selections_get",
38
- "save_layout_selections_brand_kit_layout_selections_put",
39
- "list_seo_audits_seo_audits_get",
40
- "start_seo_audit_seo_audits_post",
41
- "get_seo_audit_seo_audits__audit_id__get",
42
27
  "get_seo_audit_issues_seo_audits__audit_id__issues_get",
28
+ "get_seo_audit_seo_audits__audit_id__get",
29
+ "list_archetypes_brand_kit_archetypes_get",
30
+ "list_content_pages_content_pages_get",
31
+ "list_landing_page_versions_landing_pages__landing_page_id__versions_get",
32
+ "list_landing_pages_landing_pages_get",
33
+ "list_seo_audits_seo_audits_get",
43
34
  "list_seo_tracked_keywords_seo_rank_tracking_get",
35
+ "publish_landing_page_landing_pages__landing_page_id__publish_post",
36
+ "remove_seo_tracked_keywords_seo_rank_tracking_keywords_remove_post",
37
+ "restore_landing_page_version_landing_pages__landing_page_id__versions__version_num__restore_post",
38
+ "save_layout_selections_brand_kit_layout_selections_put",
44
39
  "save_seo_tracked_keywords_seo_rank_tracking_keywords_post",
45
- "remove_seo_tracked_keywords_seo_rank_tracking_keywords_remove_post"
40
+ "start_seo_audit_seo_audits_post",
41
+ "trigger_brand_extraction_brand_kit_extract_post",
42
+ "unpublish_landing_page_landing_pages__landing_page_id__unpublish_post",
43
+ "update_content_page_content_pages__page_id__patch",
44
+ "update_landing_page_landing_pages__landing_page_id__patch",
45
+ "update_landing_page_section_landing_pages__landing_page_id__sections__section_idx__patch"
46
46
  ],
47
47
  "components": [
48
+ {
49
+ "name": "PagesScreen",
50
+ "file": "components/pages-screen.tsx",
51
+ "purpose": "Finished marketing-site workspace: landing pages with beside-the-list editor, content library, brand kit and SEO tabs.",
52
+ "operations": [
53
+ "get_landing_page_landing_pages__landing_page_id__get",
54
+ "list_landing_pages_landing_pages_get"
55
+ ],
56
+ "tiers": [
57
+ "read"
58
+ ]
59
+ },
48
60
  {
49
61
  "name": "PageList",
50
62
  "file": "components/page-list.tsx",
@@ -218,6 +230,7 @@
218
230
  }
219
231
  ],
220
232
  "peerDependencies": {
221
- "react": ">=18"
233
+ "react": ">=18",
234
+ "lucide-react": ">=0.400.0"
222
235
  }
223
236
  }
@@ -99,7 +99,9 @@ function Layouts() {
99
99
  const picked = edit ?? saved;
100
100
  const save = useG8Mutation(() => g8Put("/brand-kit/layout-selections", { archetype_ids: picked }), { onSuccess: () => { setEdit(null); void selections.refetch(); } });
101
101
  const rows = rowsOf<Rec>(archetypes.data?.data);
102
- const dirty = edit !== null && JSON.stringify([...picked].sort()) !== JSON.stringify([...saved].sort());
102
+ // The saved selection must be known before anything is ticked or saved: ticking first would replace it with just that box.
103
+ const savedKnown = selections.data !== undefined && !selections.error;
104
+ const dirty = savedKnown && edit !== null && JSON.stringify([...picked].sort()) !== JSON.stringify([...saved].sort());
103
105
  const toggle = (id: string) => setEdit(picked.includes(id) ? picked.filter((p) => p !== id) : [...picked, id]);
104
106
  return (
105
107
  <Card>
@@ -107,13 +109,14 @@ function Layouts() {
107
109
  <CardContent className="space-y-3">
108
110
  <p className="text-muted-foreground text-sm">New landing pages are built from the layouts you tick.</p>
109
111
  {archetypes.error ? <ApiError error={archetypes.error} onRetry={() => void archetypes.refetch()} /> : null}
112
+ {selections.error ? <ApiError error={selections.error} title="Could not load your saved layouts" onRetry={() => void selections.refetch()} /> : null}
110
113
  {archetypes.loading && !archetypes.data ? <Skeleton className="h-24 w-full" /> : null}
111
114
  <div className="grid gap-2 sm:grid-cols-2">
112
115
  {rows.map((a) => {
113
116
  const id = text(a.id);
114
117
  return (
115
118
  <label key={id} className="flex cursor-pointer items-start gap-2 rounded-md border p-3 text-sm">
116
- <input type="checkbox" checked={picked.includes(id)} onChange={() => toggle(id)} aria-label={text(a.name)} />
119
+ <input type="checkbox" checked={picked.includes(id)} disabled={!savedKnown} onChange={() => toggle(id)} aria-label={text(a.name)} />
117
120
  <span><span className="font-medium">{text(a.name)}</span><br /><span className="text-muted-foreground">{text(a.description)}</span></span>
118
121
  </label>
119
122
  );
@@ -23,12 +23,16 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
23
  import { ApiError } from "@/g8/components/api-error";
24
24
  import { EmptyState } from "@/g8/components/empty-state";
25
25
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
26
- import { asRec, num, rowsOf, text } from "../lib/studio";
27
- import type { ContentPageRow } from "../lib/studio";
26
+ import { asRec, num, rowsOf, shortDate, text } from "../lib/studio";
27
+ import type { ContentPageRow, Rec } from "../lib/studio";
28
+ import { plural } from "@/g8/lib/plural";
28
29
 
29
30
  export interface ContentLibraryProps {
30
31
  pageSize?: number;
31
32
  className?: string;
33
+ /** Controlled: the content item whose full record is open (for a `?id=` deep link). */
34
+ selectedId?: string | null;
35
+ onSelectedChange?: (id: string | null) => void;
32
36
  }
33
37
 
34
38
  const TYPES = ["", "website", "blog", "linkedin", "newsletter"];
@@ -45,14 +49,32 @@ function Select({ id, value, options, onChange }: { id: string; value: string; o
45
49
  );
46
50
  }
47
51
 
48
- function EditSheet({ pageId, onClose, onSaved }: { pageId: string; onClose: () => void; onSaved: () => void }) {
52
+ /** Every read-only field the content record carries (labels and keys come from the Studio content response). */
53
+ function contentFacts(d: Rec): Array<[string, string]> {
54
+ const n = (k: string): string => (d[k] == null ? "" : text(d[k]));
55
+ const yes = (k: string): string => (d[k] == null ? "" : d[k] ? "Yes" : "No");
56
+ return [
57
+ ["Type", label(text(d.content_type, "website"))], ["Source", n("source")], ["Domain", n("domain")], ["Owned", yes("is_owned")],
58
+ ["H1", n("h1")], ["Meta description", n("meta_description")], ["Words", n("word_count")], ["Crawl status", n("crawl_status")],
59
+ ["SEO score", n("seo_score")], ["AEO score", n("aeo_score")], ["Freshness", n("freshness_score")], ["Priority", n("priority_score")],
60
+ ["Authority", n("authority_score")], ["Backlinks", n("backlinks_total")], ["Referring domains", n("referring_domains_count")], ["Organic keywords", n("organic_keywords_count")],
61
+ ["Keyword position", n("primary_keyword_position")], ["Keyword volume", n("primary_keyword_volume")], ["LLM grade", n("llm_grade")], ["Cited by LLMs", yes("llm_cited")],
62
+ ["AI overview", yes("ai_overview_present")], ["Clicks", n("gsc_clicks")], ["Impressions", n("gsc_impressions")], ["CTR", n("gsc_ctr")], ["Search position", n("gsc_position")],
63
+ ["Published", shortDate(d.published_at)], ["Last crawled", shortDate(d.last_crawled_at)], ["Created", shortDate(d.created_at)], ["Last synced", shortDate(d.semrush_last_synced)],
64
+ ];
65
+ }
66
+
67
+ function EditSheet({ pageId, onClose, onSaved, onAction }: { pageId: string; onClose: () => void; onSaved: () => void; onAction: (kind: "enrich" | "delete", page: ContentPageRow) => void }) {
49
68
  const detail = useG8Query((signal) => g8Get(`/content/pages/${encodeURIComponent(pageId)}`, undefined, { signal }), [pageId]);
50
69
  const [form, setForm] = useState({ title: "", primary_keyword: "", content_status: "idea", body_content: "" });
51
70
  const d = asRec(detail.data?.data);
52
71
  useEffect(() => {
53
72
  setForm({ title: text(d.title), primary_keyword: text(d.primary_keyword), content_status: text(d.content_status, "idea"), body_content: text(d.body_content) });
54
73
  }, [detail.data]);
55
- const save = useG8Mutation(() => g8Patch(`/content/pages/${encodeURIComponent(pageId)}`, form), { onSuccess: () => { onSaved(); onClose(); } });
74
+ // Only the fields the person changed are sent, so nothing they did not touch can be blanked.
75
+ const original = { title: text(d.title), primary_keyword: text(d.primary_keyword), content_status: text(d.content_status, "idea"), body_content: text(d.body_content) };
76
+ const changed = Object.fromEntries(Object.entries(form).filter(([k, v]) => v !== original[k as keyof typeof original]));
77
+ const save = useG8Mutation(() => g8Patch(`/content/pages/${encodeURIComponent(pageId)}`, changed), { onSuccess: () => { onSaved(); onClose(); } });
56
78
  return (
57
79
  <Sheet open onOpenChange={(open) => { if (!open) onClose(); }}>
58
80
  <SheetContent className="w-full overflow-y-auto sm:max-w-xl">
@@ -62,14 +84,24 @@ function EditSheet({ pageId, onClose, onSaved }: { pageId: string; onClose: () =
62
84
  </SheetHeader>
63
85
  <div className="space-y-3 px-4 pb-4">
64
86
  {detail.error ? <ApiError error={detail.error} onRetry={() => void detail.refetch()} /> : null}
65
- {detail.loading && !detail.data ? <Skeleton className="h-32 w-full" /> : (
87
+ {/* The form (and Save) exist only once the page has loaded: after a failed load there is nothing to edit over. */}
88
+ {!detail.data ? (detail.error ? null : <Skeleton className="h-32 w-full" />) : (
66
89
  <>
90
+ <dl className="grid grid-cols-2 gap-2 text-sm">
91
+ {contentFacts(d).map(([k, v]) => (
92
+ <div key={k}><dt className="text-muted-foreground text-xs">{k}</dt><dd className="truncate" title={v}>{v || "Not set"}</dd></div>
93
+ ))}
94
+ </dl>
95
+ <div className="flex gap-2">
96
+ <Button size="sm" variant="outline" onClick={() => onAction("enrich", { id: pageId, url: text(d.url), title: text(d.title) })}>Enrich SEO</Button>
97
+ <Button size="sm" variant="ghost" onClick={() => onAction("delete", { id: pageId, url: text(d.url), title: text(d.title) })}>Delete page</Button>
98
+ </div>
67
99
  <div className="space-y-1"><Label htmlFor="cl-title">Title</Label><Input id="cl-title" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></div>
68
100
  <div className="space-y-1"><Label htmlFor="cl-kw">Primary keyword</Label><Input id="cl-kw" value={form.primary_keyword} onChange={(e) => setForm({ ...form, primary_keyword: e.target.value })} /></div>
69
101
  <div className="space-y-1"><Label htmlFor="cl-status">Status</Label><Select id="cl-status" value={form.content_status} options={STATUSES.slice(1)} onChange={(v) => setForm({ ...form, content_status: v })} /></div>
70
102
  <div className="space-y-1"><Label htmlFor="cl-body">Body</Label><Textarea id="cl-body" rows={10} value={form.body_content} onChange={(e) => setForm({ ...form, body_content: e.target.value })} /></div>
71
103
  {save.error ? <ApiError error={save.error} title="Could not save" /> : null}
72
- <Button loading={save.loading} onClick={() => void save.mutate(undefined)}>Save</Button>
104
+ <Button loading={save.loading} disabled={Object.keys(changed).length === 0} onClick={() => void save.mutate(undefined)}>Save</Button>
73
105
  </>
74
106
  )}
75
107
  </div>
@@ -78,12 +110,15 @@ function EditSheet({ pageId, onClose, onSaved }: { pageId: string; onClose: () =
78
110
  );
79
111
  }
80
112
 
81
- export function ContentLibrary({ pageSize = 25, className }: ContentLibraryProps) {
113
+ export function ContentLibrary({ pageSize = 25, className, selectedId, onSelectedChange }: ContentLibraryProps) {
82
114
  const [type, setType] = useState("");
83
115
  const [status, setStatus] = useState("");
84
116
  const [offset, setOffset] = useState(0);
85
117
  const [url, setUrl] = useState("");
86
- const [editing, setEditing] = useState<string | null>(null);
118
+ const [localEditing, setLocalEditing] = useState<string | null>(null);
119
+ const controlled = onSelectedChange !== undefined;
120
+ const editing = controlled ? (selectedId ?? null) : localEditing;
121
+ const setEditing = (id: string | null): void => (controlled ? onSelectedChange?.(id) : setLocalEditing(id));
87
122
  const [action, setAction] = useState<Action | null>(null);
88
123
 
89
124
  const list = useG8Query(
@@ -93,7 +128,7 @@ export function ContentLibrary({ pageSize = 25, className }: ContentLibraryProps
93
128
  const add = useG8Mutation((u: string) => g8Post("/content/pages", { url: u, content_type: type || "website" }), { onSuccess: () => { setUrl(""); void list.refetch(); } });
94
129
  const run = useG8Mutation(
95
130
  (a: Action) => (a.kind === "enrich" ? g8Post(`/content/pages/${encodeURIComponent(a.page.id)}/enrich-seo`) : g8Delete(`/content/pages/${encodeURIComponent(a.page.id)}`)),
96
- { onSuccess: () => { setAction(null); void list.refetch(); } },
131
+ { onSuccess: (_r, a) => { setAction(null); if (a.kind === "delete" && a.page.id === editing) setEditing(null); void list.refetch(); } },
97
132
  );
98
133
 
99
134
  const data = asRec(list.data?.data);
@@ -143,14 +178,14 @@ export function ContentLibrary({ pageSize = 25, className }: ContentLibraryProps
143
178
  </Table>
144
179
  )}
145
180
  <div className="mt-3 flex items-center justify-between text-sm">
146
- <span className="text-muted-foreground">{total} {total === 1 ? "item" : "items"}</span>
181
+ <span className="text-muted-foreground">{plural(total, "item")}</span>
147
182
  <div className="flex gap-2">
148
183
  <Button size="sm" variant="outline" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - pageSize))}>Previous</Button>
149
184
  <Button size="sm" variant="outline" disabled={offset + pageSize >= total} onClick={() => setOffset(offset + pageSize)}>Next</Button>
150
185
  </div>
151
186
  </div>
152
187
 
153
- {editing ? <EditSheet pageId={editing} onClose={() => setEditing(null)} onSaved={() => void list.refetch()} /> : null}
188
+ {editing ? <EditSheet pageId={editing} onClose={() => setEditing(null)} onSaved={() => void list.refetch()} onAction={(kind, page) => setAction({ kind, page })} /> : null}
154
189
  {action ? (
155
190
  <ConfirmSpendDialog open tier={action.kind === "enrich" ? "billable" : "destructive"}
156
191
  title={action.kind === "enrich" ? `Enrich SEO for "${name(action.page)}"` : `Delete "${name(action.page)}"`}
@@ -25,10 +25,13 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
25
25
  import type { ConfirmTier } from "@/g8/components/confirm-spend-dialog";
26
26
  import { asRec, num, rowsOf, shortDate, text } from "../lib/studio";
27
27
  import type { LandingPageRow } from "../lib/studio";
28
+ import { plural } from "@/g8/lib/plural";
28
29
 
29
30
  export interface PageListProps {
30
31
  /** Called with the page id when a row is opened. Wire it to your PageEditor route. */
31
32
  onOpen?: (pageId: string) => void;
33
+ /** Called with the page id after a page is deleted (for example to close it if it is open beside the list). */
34
+ onDeleted?: (pageId: string) => void;
32
35
  pageSize?: number;
33
36
  className?: string;
34
37
  }
@@ -56,7 +59,7 @@ function confirmCopy(p: Pending): { tier: ConfirmTier; title: string; descriptio
56
59
  return { tier: "destructive", title: `Delete "${p.page.name}"`, description: "The page and its version history are removed.", label: "Delete", affected: 1 };
57
60
  }
58
61
 
59
- export function PageList({ onOpen, pageSize = 25, className }: PageListProps) {
62
+ export function PageList({ onOpen, onDeleted, pageSize = 25, className }: PageListProps) {
60
63
  const [status, setStatus] = useState("");
61
64
  const [offset, setOffset] = useState(0);
62
65
  const [name, setName] = useState("");
@@ -72,7 +75,7 @@ export function PageList({ onOpen, pageSize = 25, className }: PageListProps) {
72
75
  if (p.kind === "publish") return g8Post(`${base}/publish`);
73
76
  if (p.kind === "unpublish") return g8Post(`${base}/unpublish`);
74
77
  return g8Delete(base);
75
- }, { onSuccess: () => { setPending(null); void list.refetch(); } });
78
+ }, { onSuccess: (_res, p) => { setPending(null); void list.refetch(); if (p.kind === "delete") onDeleted?.(p.page.id); } });
76
79
  const blank = useG8Mutation((title: string) => g8Post("/landing-pages", { name: title || undefined, generate: false }), {
77
80
  onSuccess: (res) => {
78
81
  setName("");
@@ -143,7 +146,7 @@ export function PageList({ onOpen, pageSize = 25, className }: PageListProps) {
143
146
  )}
144
147
 
145
148
  <div className="mt-3 flex items-center justify-between text-sm">
146
- <span className="text-muted-foreground">{total} {total === 1 ? "page" : "pages"}</span>
149
+ <span className="text-muted-foreground">{plural(total, "page")}</span>
147
150
  <div className="flex gap-2">
148
151
  <Button size="sm" variant="outline" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - pageSize))}>Previous</Button>
149
152
  <Button size="sm" variant="outline" disabled={offset + pageSize >= total} onClick={() => setOffset(offset + pageSize)}>Next</Button>