@graph8/sdk 0.362.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 (349) hide show
  1. package/README.md +14 -10
  2. package/blocks/_foundation/README.md +64 -1
  3. package/blocks/_foundation/block.json +79 -6
  4. package/blocks/_foundation/components/api-error.tsx +9 -7
  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/screen/index.ts +1 -0
  9. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  10. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  11. package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
  12. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  13. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  14. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  15. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  16. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  17. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  18. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  19. package/blocks/_foundation/lib/g8-client.ts +1 -1
  20. package/blocks/_foundation/lib/plural.ts +23 -0
  21. package/blocks/_foundation/server/connect-route.ts +15 -0
  22. package/blocks/_foundation/server/connect.ts +127 -0
  23. package/blocks/_foundation/server/local-key.ts +70 -0
  24. package/blocks/_foundation/server/proxy.ts +103 -3
  25. package/blocks/_foundation/server/route.ts +16 -4
  26. package/blocks/agents/README.md +14 -0
  27. package/blocks/agents/block.json +33 -1
  28. package/blocks/agents/components/agent-builder.tsx +13 -8
  29. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
  30. package/blocks/agents/components/agent-run-detail.tsx +102 -0
  31. package/blocks/agents/components/agent-runs.tsx +16 -7
  32. package/blocks/agents/components/agents-screen.tsx +184 -0
  33. package/blocks/analytics/README.md +13 -0
  34. package/blocks/analytics/block.json +45 -1
  35. package/blocks/analytics/components/analytics-screen.tsx +209 -0
  36. package/blocks/analytics/components/leaderboard.tsx +14 -7
  37. package/blocks/analytics/components/report-builder.tsx +23 -9
  38. package/blocks/analytics/components/report-detail.tsx +84 -0
  39. package/blocks/analytics/components/trend-chart.tsx +62 -0
  40. package/blocks/appointments/block.json +6 -2
  41. package/blocks/appointments/components/availability-editor.tsx +17 -5
  42. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  43. package/blocks/appointments/components/event-type-related.tsx +4 -2
  44. package/blocks/appointments/components/meeting-list.tsx +9 -5
  45. package/blocks/appointments/components/meetings-screen.tsx +7 -4
  46. package/blocks/appointments/components/scheduling-screen.tsx +2 -1
  47. package/blocks/block.schema.json +18 -0
  48. package/blocks/block.types.ts +17 -0
  49. package/blocks/campaigns/README.md +41 -11
  50. package/blocks/campaigns/block.json +105 -42
  51. package/blocks/campaigns/components/campaign-assets.tsx +16 -5
  52. package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
  53. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  54. package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
  55. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  56. package/blocks/campaigns/components/icp-detail.tsx +154 -0
  57. package/blocks/campaigns/components/icp-editor.tsx +15 -15
  58. package/blocks/campaigns/components/persona-detail.tsx +172 -0
  59. package/blocks/campaigns/components/persona-editor.tsx +14 -11
  60. package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
  61. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  62. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  63. package/blocks/coach/README.md +10 -0
  64. package/blocks/coach/block.json +16 -1
  65. package/blocks/coach/components/coach-screen.tsx +222 -0
  66. package/blocks/copilot/README.md +13 -0
  67. package/blocks/copilot/block.json +14 -1
  68. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  69. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  70. package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
  71. package/blocks/crm/README.md +28 -0
  72. package/blocks/crm/block.json +58 -2
  73. package/blocks/crm/components/companies-screen.tsx +345 -0
  74. package/blocks/crm/components/company-detail.tsx +8 -6
  75. package/blocks/crm/components/company-table.tsx +11 -4
  76. package/blocks/crm/components/contact-detail.tsx +6 -3
  77. package/blocks/crm/components/contact-table.tsx +10 -3
  78. package/blocks/crm/components/contacts-screen.tsx +359 -0
  79. package/blocks/crm/components/dedupe-review.tsx +7 -5
  80. package/blocks/crm/components/import-wizard.tsx +4 -2
  81. package/blocks/crm/components/notes-panel.tsx +4 -4
  82. package/blocks/crm/components/record-form.tsx +5 -3
  83. package/blocks/crm/components/task-list.tsx +4 -4
  84. package/blocks/crm/components/tasks-screen.tsx +379 -0
  85. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  86. package/blocks/crm/hooks/use-page-query.ts +7 -1
  87. package/blocks/crm/lib/crm-types.ts +12 -2
  88. package/blocks/crm/lib/screen-parts.tsx +105 -0
  89. package/blocks/deals/block.json +9 -3
  90. package/blocks/deals/components/deal-detail.tsx +23 -11
  91. package/blocks/deals/components/deal-related.tsx +1 -1
  92. package/blocks/deals/components/deals-screen.tsx +10 -5
  93. package/blocks/deals/components/stage-editor.tsx +4 -4
  94. package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
  95. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  96. package/blocks/dialer/README.md +34 -2
  97. package/blocks/dialer/block.json +67 -1
  98. package/blocks/dialer/components/call-contact-button.tsx +252 -0
  99. package/blocks/dialer/components/call-log-screen.tsx +205 -0
  100. package/blocks/dialer/components/call-log.tsx +7 -3
  101. package/blocks/dialer/components/call-queue.tsx +1 -1
  102. package/blocks/dialer/components/dialer-screen.tsx +319 -0
  103. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  104. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  105. package/blocks/dialer/components/session-start-form.tsx +4 -2
  106. package/blocks/dialer/components/softphone.tsx +3 -1
  107. package/blocks/enrichment/README.md +14 -2
  108. package/blocks/enrichment/block.json +32 -4
  109. package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
  110. package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
  111. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  112. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  113. package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
  114. package/blocks/enrichment/components/waterfall-config.tsx +39 -10
  115. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  116. package/blocks/forms/block.json +32 -18
  117. package/blocks/forms/components/form-builder.tsx +3 -1
  118. package/blocks/forms/components/form-embed.tsx +3 -1
  119. package/blocks/forms/components/forms-screen.tsx +5 -2
  120. package/blocks/forms/components/submissions-table.tsx +3 -1
  121. package/blocks/inbox/README.md +27 -3
  122. package/blocks/inbox/block.json +14 -2
  123. package/blocks/inbox/components/inbox-list.tsx +7 -3
  124. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  125. package/blocks/inbox/components/reply-composer.tsx +65 -9
  126. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  127. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  128. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  129. package/blocks/inbox/lib/inbox-types.ts +47 -1
  130. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  131. package/blocks/integrations/README.md +9 -0
  132. package/blocks/integrations/block.json +46 -1
  133. package/blocks/integrations/components/crm-detail.tsx +100 -0
  134. package/blocks/integrations/components/integration-list.tsx +9 -4
  135. package/blocks/integrations/components/integrations-screen.tsx +135 -0
  136. package/blocks/integrations/components/sync-status.tsx +5 -3
  137. package/blocks/integrations/components/webhook-detail.tsx +160 -0
  138. package/blocks/integrations/components/webhook-manager.tsx +13 -8
  139. package/blocks/integrations/lib/integrations-utils.ts +6 -1
  140. package/blocks/intent/README.md +14 -0
  141. package/blocks/intent/block.json +27 -1
  142. package/blocks/intent/components/intent-feed.tsx +5 -2
  143. package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
  144. package/blocks/intent/components/intent-screen.tsx +265 -0
  145. package/blocks/intent/components/keyword-detail.tsx +124 -0
  146. package/blocks/lifecycle/block.json +13 -8
  147. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  148. package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
  149. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  150. package/blocks/lists/README.md +13 -0
  151. package/blocks/lists/block.json +39 -1
  152. package/blocks/lists/components/add-to-list-button.tsx +3 -2
  153. package/blocks/lists/components/list-picker.tsx +5 -1
  154. package/blocks/lists/components/list-table.tsx +11 -5
  155. package/blocks/lists/components/lists-screen.tsx +344 -0
  156. package/blocks/lists/components/suppression-manager.tsx +6 -2
  157. package/blocks/lists/components/suppressions-screen.tsx +255 -0
  158. package/blocks/mailboxes/README.md +17 -0
  159. package/blocks/mailboxes/block.json +65 -17
  160. package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
  161. package/blocks/mailboxes/components/domain-detail.tsx +142 -0
  162. package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
  163. package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
  164. package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
  165. package/blocks/mailboxes/components/warmup-health.tsx +4 -2
  166. package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
  167. package/blocks/newsletter/block.json +40 -24
  168. package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
  169. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  170. package/blocks/nurture/block.json +28 -14
  171. package/blocks/nurture/components/nurture-builder.tsx +3 -1
  172. package/blocks/nurture/components/nurture-enroll.tsx +6 -3
  173. package/blocks/nurture/components/nurture-form.tsx +23 -3
  174. package/blocks/nurture/components/nurture-screen.tsx +10 -6
  175. package/blocks/nurture/lib/nurture-types.ts +1 -1
  176. package/blocks/objects/README.md +17 -0
  177. package/blocks/objects/block.json +29 -16
  178. package/blocks/objects/components/object-not-found.tsx +3 -1
  179. package/blocks/objects/components/object-record-detail.tsx +2 -1
  180. package/blocks/objects/components/object-schema-editor.tsx +3 -1
  181. package/blocks/objects/components/object-table.tsx +13 -3
  182. package/blocks/objects/components/objects-screen.tsx +147 -0
  183. package/blocks/objects/lib/object-types.ts +1 -1
  184. package/blocks/pages/block.json +28 -23
  185. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  186. package/blocks/pages/components/content-library.tsx +9 -4
  187. package/blocks/pages/components/page-list.tsx +2 -1
  188. package/blocks/quotes/block.json +17 -12
  189. package/blocks/quotes/components/product-picker.tsx +3 -1
  190. package/blocks/quotes/components/quote-related.tsx +5 -2
  191. package/blocks/radar/README.md +10 -0
  192. package/blocks/radar/block.json +41 -1
  193. package/blocks/radar/components/competitor-detail.tsx +117 -0
  194. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  195. package/blocks/radar/components/radar-report.tsx +4 -2
  196. package/blocks/radar/components/radar-screen.tsx +214 -0
  197. package/blocks/radar/components/report-detail.tsx +104 -0
  198. package/blocks/search/README.md +10 -0
  199. package/blocks/search/block.json +16 -1
  200. package/blocks/search/components/filter-builder.tsx +3 -3
  201. package/blocks/search/components/prospect-results.tsx +5 -3
  202. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  203. package/blocks/search/components/saved-searches.tsx +4 -1
  204. package/blocks/search/hooks/use-filter-options.ts +20 -6
  205. package/blocks/search/lib/search-types.ts +2 -0
  206. package/blocks/sequencer/README.md +49 -9
  207. package/blocks/sequencer/block.json +99 -19
  208. package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
  209. package/blocks/sequencer/components/sequence-builder.tsx +211 -46
  210. package/blocks/sequencer/components/sequence-detail.tsx +259 -0
  211. package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
  212. package/blocks/sequencer/components/sequence-list.tsx +33 -8
  213. package/blocks/sequencer/components/sequence-stats.tsx +3 -2
  214. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  215. package/blocks/sequencer/components/sequences-screen.tsx +159 -0
  216. package/blocks/sequencer/components/step-editor.tsx +3 -1
  217. package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
  218. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  219. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  220. package/blocks/social-ads/README.md +11 -0
  221. package/blocks/social-ads/block.json +101 -2
  222. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  223. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  224. package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
  225. package/blocks/social-ads/components/audience-sync.tsx +11 -7
  226. package/blocks/social-ads/components/campaign-detail.tsx +133 -0
  227. package/blocks/social-ads/components/creative-detail.tsx +150 -0
  228. package/blocks/social-ads/components/creative-gallery.tsx +15 -5
  229. package/blocks/social-ads/components/post-composer.tsx +3 -1
  230. package/blocks/social-ads/components/post-detail.tsx +160 -0
  231. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  232. package/blocks/social-ads/components/social-screen.tsx +160 -0
  233. package/blocks/visitors/README.md +10 -0
  234. package/blocks/visitors/block.json +25 -1
  235. package/blocks/visitors/components/snippet-install.tsx +3 -1
  236. package/blocks/visitors/components/visitor-company-card.tsx +2 -2
  237. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  238. package/blocks/visitors/components/visitor-feed.tsx +11 -2
  239. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  240. package/blocks/webchat/README.md +10 -0
  241. package/blocks/webchat/block.json +21 -7
  242. package/blocks/webchat/components/chat-widget-config.tsx +7 -4
  243. package/blocks/webchat/components/conversation-list.tsx +7 -3
  244. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  245. package/blocks/workbench/README.md +11 -0
  246. package/blocks/workbench/block.json +48 -1
  247. package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
  248. package/blocks/workbench/components/workbench-grid.tsx +9 -3
  249. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  250. package/blocks/workbench/components/workbench-screen.tsx +192 -0
  251. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  252. package/blocks/workflows/README.md +13 -0
  253. package/blocks/workflows/block.json +28 -9
  254. package/blocks/workflows/components/run-history.tsx +31 -9
  255. package/blocks/workflows/components/trigger-config.tsx +3 -1
  256. package/blocks/workflows/components/workflow-editor.tsx +3 -1
  257. package/blocks/workflows/components/workflow-list.tsx +13 -6
  258. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  259. package/dist/cli.js +2709 -1721
  260. package/dist/errors.d.mts +34 -0
  261. package/dist/errors.d.ts +34 -0
  262. package/dist/errors.js +46 -0
  263. package/dist/errors.js.map +1 -0
  264. package/dist/errors.mjs +21 -0
  265. package/dist/errors.mjs.map +1 -0
  266. package/dist/index.browser.js +11 -5
  267. package/dist/index.browser.js.map +1 -1
  268. package/dist/index.browser.mjs +11 -5
  269. package/dist/index.browser.mjs.map +1 -1
  270. package/dist/index.d.mts +164 -25
  271. package/dist/index.d.ts +164 -25
  272. package/dist/index.js +11 -5
  273. package/dist/index.js.map +1 -1
  274. package/dist/index.mjs +11 -5
  275. package/dist/index.mjs.map +1 -1
  276. package/dist/react.browser.js +10 -4
  277. package/dist/react.browser.js.map +1 -1
  278. package/dist/react.browser.mjs +10 -4
  279. package/dist/react.browser.mjs.map +1 -1
  280. package/dist/react.d.mts +142 -5
  281. package/dist/react.d.ts +142 -5
  282. package/dist/react.js +10 -4
  283. package/dist/react.js.map +1 -1
  284. package/dist/react.mjs +10 -4
  285. package/dist/react.mjs.map +1 -1
  286. package/dist/types/blocks/add.d.ts +11 -4
  287. package/dist/types/blocks/brand.d.ts +113 -0
  288. package/dist/types/blocks/compose.d.ts +53 -0
  289. package/dist/types/blocks/create.d.ts +22 -19
  290. package/dist/types/blocks/doctor.d.ts +5 -1
  291. package/dist/types/blocks/navfile.d.ts +35 -0
  292. package/dist/types/blocks/paths.d.ts +4 -0
  293. package/dist/types/blocks/prefixes.d.ts +37 -0
  294. package/dist/types/blocks/registry.d.ts +17 -0
  295. package/dist/types/blocks/screens.d.ts +53 -0
  296. package/dist/types/cli.d.ts +2 -0
  297. package/dist/types/errors.d.ts +33 -0
  298. package/dist/types/http.d.ts +2 -18
  299. package/dist/types/version.d.ts +2 -2
  300. package/docs/agents.md +15 -4
  301. package/docs/api/README.md +2 -2
  302. package/docs/api/by-path.md +1 -0
  303. package/docs/api/desk.d.ts +98 -2
  304. package/docs/api/desk.md +123 -1
  305. package/docs/api/inbox.d.ts +10 -2
  306. package/docs/api/inbox.md +10 -2
  307. package/docs/api/seo.d.ts +19 -1
  308. package/docs/api/seo.md +24 -1
  309. package/package.json +16 -2
  310. package/stacks/6sense.json +41 -16
  311. package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
  312. package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
  313. package/stacks/apollo.json +118 -36
  314. package/stacks/calendly.json +60 -0
  315. package/stacks/clay/pages/companies.tsx.tmpl +15 -0
  316. package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
  317. package/stacks/clay.json +59 -26
  318. package/stacks/gong.json +81 -35
  319. package/stacks/hootsuite.json +99 -0
  320. package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
  321. package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
  322. package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
  323. package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
  324. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  325. package/stacks/hubspot.json +167 -66
  326. package/stacks/intercom.json +48 -20
  327. package/stacks/mailchimp.json +52 -33
  328. package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
  329. package/stacks/outreach.json +88 -25
  330. package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
  331. package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
  332. package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
  333. package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
  334. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  335. package/stacks/pipedrive.json +115 -0
  336. package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
  337. package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
  338. package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
  339. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  340. package/stacks/salesforce.json +81 -31
  341. package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
  342. package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
  343. package/stacks/zoominfo.json +102 -0
  344. package/styles/styles.css +1 -1
  345. package/templates/next-app/README.md.tmpl +40 -9
  346. package/templates/next-app/app/layout.tsx.tmpl +7 -2
  347. package/templates/next-app/components/app-shell.tsx.tmpl +99 -89
  348. package/templates/next-app/dot-gitignore.tmpl +2 -0
  349. package/templates/next-app/package.json.tmpl +1 -1
@@ -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"
@@ -14,43 +14,48 @@
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
48
  {
49
49
  "name": "PagesScreen",
50
50
  "file": "components/pages-screen.tsx",
51
51
  "purpose": "Finished marketing-site workspace: landing pages with beside-the-list editor, content library, brand kit and SEO tabs.",
52
- "operations": [],
53
- "tiers": []
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
+ ]
54
59
  },
55
60
  {
56
61
  "name": "PageList",
@@ -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
  );
@@ -25,6 +25,7 @@ import { EmptyState } from "@/g8/components/empty-state";
25
25
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
26
26
  import { asRec, num, rowsOf, shortDate, text } from "../lib/studio";
27
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;
@@ -70,7 +71,10 @@ function EditSheet({ pageId, onClose, onSaved, onAction }: { pageId: string; onC
70
71
  useEffect(() => {
71
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) });
72
73
  }, [detail.data]);
73
- 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(); } });
74
78
  return (
75
79
  <Sheet open onOpenChange={(open) => { if (!open) onClose(); }}>
76
80
  <SheetContent className="w-full overflow-y-auto sm:max-w-xl">
@@ -80,7 +84,8 @@ function EditSheet({ pageId, onClose, onSaved, onAction }: { pageId: string; onC
80
84
  </SheetHeader>
81
85
  <div className="space-y-3 px-4 pb-4">
82
86
  {detail.error ? <ApiError error={detail.error} onRetry={() => void detail.refetch()} /> : null}
83
- {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" />) : (
84
89
  <>
85
90
  <dl className="grid grid-cols-2 gap-2 text-sm">
86
91
  {contentFacts(d).map(([k, v]) => (
@@ -96,7 +101,7 @@ function EditSheet({ pageId, onClose, onSaved, onAction }: { pageId: string; onC
96
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>
97
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>
98
103
  {save.error ? <ApiError error={save.error} title="Could not save" /> : null}
99
- <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>
100
105
  </>
101
106
  )}
102
107
  </div>
@@ -173,7 +178,7 @@ export function ContentLibrary({ pageSize = 25, className, selectedId, onSelecte
173
178
  </Table>
174
179
  )}
175
180
  <div className="mt-3 flex items-center justify-between text-sm">
176
- <span className="text-muted-foreground">{total} {total === 1 ? "item" : "items"}</span>
181
+ <span className="text-muted-foreground">{plural(total, "item")}</span>
177
182
  <div className="flex gap-2">
178
183
  <Button size="sm" variant="outline" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - pageSize))}>Previous</Button>
179
184
  <Button size="sm" variant="outline" disabled={offset + pageSize >= total} onClick={() => setOffset(offset + pageSize)}>Next</Button>
@@ -25,6 +25,7 @@ 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. */
@@ -145,7 +146,7 @@ export function PageList({ onOpen, onDeleted, pageSize = 25, className }: PageLi
145
146
  )}
146
147
 
147
148
  <div className="mt-3 flex items-center justify-between text-sm">
148
- <span className="text-muted-foreground">{total} {total === 1 ? "page" : "pages"}</span>
149
+ <span className="text-muted-foreground">{plural(total, "page")}</span>
149
150
  <div className="flex gap-2">
150
151
  <Button size="sm" variant="outline" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - pageSize))}>Previous</Button>
151
152
  <Button size="sm" variant="outline" disabled={offset + pageSize >= total} onClick={() => setOffset(offset + pageSize)}>Next</Button>
@@ -14,19 +14,19 @@
14
14
  "deals"
15
15
  ],
16
16
  "operations": [
17
- "list_quotes_quotes_get",
18
- "get_quote_quotes__quote_id__get",
19
- "search_quote_contacts_quote_contacts_get",
20
17
  "create_quote_quotes_post",
21
- "update_quote_quotes__quote_id__patch",
22
- "list_quotable_products_quotable_products_get",
23
- "send_quote_quotes__quote_id__send_post",
24
- "edit_quote_as_draft_quotes__quote_id__edit_as_draft_post",
25
- "duplicate_quote_quotes__quote_id__duplicate_post",
26
18
  "delete_quote_quotes__quote_id__delete",
27
- "resend_envelope_envelopes__envelope_id__resend_post",
19
+ "duplicate_quote_quotes__quote_id__duplicate_post",
20
+ "edit_quote_as_draft_quotes__quote_id__edit_as_draft_post",
21
+ "get_deal_deals__deal_id__get",
22
+ "get_quote_quotes__quote_id__get",
28
23
  "list_deals_deals_get",
29
- "get_deal_deals__deal_id__get"
24
+ "list_quotable_products_quotable_products_get",
25
+ "list_quotes_quotes_get",
26
+ "resend_envelope_envelopes__envelope_id__resend_post",
27
+ "search_quote_contacts_quote_contacts_get",
28
+ "send_quote_quotes__quote_id__send_post",
29
+ "update_quote_quotes__quote_id__patch"
30
30
  ],
31
31
  "components": [
32
32
  {
@@ -100,8 +100,13 @@
100
100
  "name": "QuotesScreen",
101
101
  "file": "components/quotes-screen.tsx",
102
102
  "purpose": "Finished quotes product: status numbers, list, viewer with send and signature tracking, deal-first builder and draft editing.",
103
- "operations": [],
104
- "tiers": []
103
+ "operations": [
104
+ "list_deals_deals_get",
105
+ "list_quotes_quotes_get"
106
+ ],
107
+ "tiers": [
108
+ "read"
109
+ ]
105
110
  },
106
111
  {
107
112
  "name": "ProductCatalog",
@@ -22,6 +22,7 @@ import { ApiError } from "@/g8/components/api-error";
22
22
  import { EmptyState } from "@/g8/components/empty-state";
23
23
  import { body, frequencyOf, lineFromProduct, money, text } from "../lib/quotes";
24
24
  import type { LineItem, ProductPrice, QuotableProduct } from "../lib/quotes";
25
+ import { useBrand } from "@/g8/hooks/use-brand";
25
26
 
26
27
  export interface ProductPickerProps {
27
28
  /** Only offer prices in this currency (the quote's currency). */
@@ -34,6 +35,7 @@ export interface ProductPickerProps {
34
35
  const intervalLabel = (p: ProductPrice) => (frequencyOf(p.recurring_interval) === "one_time" ? "one time" : `per ${p.recurring_interval}`);
35
36
 
36
37
  export function ProductPicker({ currency, onPick, pageSize = 25, className }: ProductPickerProps) {
38
+ const brand = useBrand();
37
39
  const [page, setPage] = useState(1);
38
40
  const [search, setSearch] = useState("");
39
41
  const q = useG8Query((signal) => g8Get("/quotable-products", { currency: currency?.toUpperCase(), page, limit: pageSize }, { signal }), [currency, page, pageSize]);
@@ -49,7 +51,7 @@ export function ProductPicker({ currency, onPick, pageSize = 25, className }: Pr
49
51
  {q.error ? <ApiError error={q.error} onRetry={() => void q.refetch()} /> : null}
50
52
  {q.loading && !q.data ? <div className="space-y-2" aria-busy="true">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-12 w-full" />)}</div> : null}
51
53
  {!q.loading && !q.error && shown.length === 0 ? (
52
- <EmptyState title="No quotable products" description="Mark a Stripe product as quotable, or add a manual product, in graph8's quote settings." />
54
+ <EmptyState title="No quotable products" description={`Mark a Stripe product as quotable, or add a manual product, in ${brand.linkLabel}'s quote settings.`} />
53
55
  ) : null}
54
56
  <ul className="divide-y rounded-md border">
55
57
  {shown.map((p) => (
@@ -27,7 +27,9 @@ import { useUrlState } from "@/g8/hooks/use-url-state";
27
27
  import { g8Get } from "@/g8/lib/g8-client";
28
28
  import { useG8Query } from "@/g8/hooks/use-g8-query";
29
29
  import { formatMoney, type Deal } from "@/g8/deals/lib/deals-types";
30
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
30
31
  import { body, frequencyOf, money, text, type QuotableProduct, type ProductPrice } from "../lib/quotes";
32
+ import { useBrand } from "@/g8/hooks/use-brand";
31
33
 
32
34
  const productKey = (p: QuotableProduct) => p.stripe_product_id ?? p.manual_product_id ?? p.name;
33
35
  const cadence = (p: ProductPrice) => (frequencyOf(p.recurring_interval) === "one_time" ? "One time" : `Per ${p.recurring_interval}`);
@@ -39,6 +41,7 @@ export interface ProductCatalogProps {
39
41
  }
40
42
 
41
43
  export function ProductCatalog({ onStartQuote, pageSize = 50 }: ProductCatalogProps) {
44
+ const brand = useBrand();
42
45
  const [selected, setSelected] = useUrlState("product");
43
46
  const [mode, setMode] = useUrlState("view");
44
47
  const q = useG8Query((signal) => g8Get("/quotable-products", { page: 1, limit: pageSize }, { signal }), [pageSize]);
@@ -48,13 +51,13 @@ export function ProductCatalog({ onStartQuote, pageSize = 50 }: ProductCatalogPr
48
51
  const close = () => { setSelected(null); setMode(null); };
49
52
 
50
53
  const list = q.error ? <ApiError error={q.error} title="Could not load products" onRetry={() => void q.refetch()} /> : q.loading && !q.data ? <Skeleton className="h-40 w-full" aria-label="Loading products" /> : items.length === 0 ? (
51
- <EmptyState icon={<Package className="size-8" />} title="No quotable products" description="Mark a Stripe product as quotable, or add a manual product, in graph8's quote settings." />
54
+ <EmptyState icon={<Package className="size-8" />} title="No quotable products" description={`Mark a Stripe product as quotable, or add a manual product, in ${brand.linkLabel}'s quote settings.`} />
52
55
  ) : (
53
56
  <Table>
54
57
  <TableHeader><TableRow><TableHead>Product</TableHead><TableHead>Source</TableHead><TableHead className="text-right">Prices</TableHead></TableRow></TableHeader>
55
58
  <TableBody>
56
59
  {items.map((p) => (
57
- <TableRow key={productKey(p)} className="cursor-pointer" data-state={productKey(p) === selected ? "selected" : undefined} onClick={() => setSelected(productKey(p))}>
60
+ <TableRow key={productKey(p)} className="cursor-pointer" data-state={productKey(p) === selected ? "selected" : undefined} {...rowActivationProps(() => setSelected(productKey(p)))}>
58
61
  <TableCell><button type="button" className="font-medium hover:underline" onClick={(e) => { e.stopPropagation(); setSelected(productKey(p)); }}>{p.name}</button><div className="text-xs text-muted-foreground">{p.category}</div></TableCell>
59
62
  <TableCell><StatusPill tone="neutral">{p.source === "stripe" ? "Stripe" : "Manual"}</StatusPill></TableCell>
60
63
  <TableCell className="text-right tabular-nums">{p.prices.length}</TableCell>
@@ -5,6 +5,16 @@ deep-dive reports. Depends only on `foundation`.
5
5
 
6
6
  Install: `npx @graph8/sdk add radar`.
7
7
 
8
+ ## Screens
9
+
10
+ `RadarScreen` (`components/radar-screen.tsx`) is the finished page: copy it as your route. No props are required.
11
+
12
+ - **Overview tab**: stat strip, threat board (click a row to open its battle card), alerts and recent moves.
13
+ - **Competitors tab**: add, bulk-add detected, scan, pause, resume and remove competitors; the selected one opens its battle card
14
+ in a side panel. Deep link: `?id=<competitor id>`. **Reports tab**: generate and open deep-dive reports.
15
+ - Scans and reports spend credits and are confirmed; removing a competitor is confirmed as destructive.
16
+ - Slots: `toolbarActions`, `renderDetailActions(record)`, `extraDetailTabs` (each `{ id, label, render(record) }`), where `record` is `{ kind: "competitor" | "report", id }`.
17
+
8
18
  ## What you get
9
19
 
10
20
  | Component | Use it for | Props |
@@ -87,6 +87,45 @@
87
87
  "read",
88
88
  "billable"
89
89
  ]
90
+ },
91
+ {
92
+ "name": "RadarScreen",
93
+ "file": "components/radar-screen.tsx",
94
+ "purpose": "Finished competitive-radar page: overview with threat board, competitor roster with battle-card panel, and reports.",
95
+ "operations": [
96
+ "get_marketing_overview_analytics_marketing_overview_get",
97
+ "overview_radar_overview_get"
98
+ ],
99
+ "tiers": [
100
+ "read"
101
+ ]
102
+ },
103
+ {
104
+ "name": "CompetitorDetail",
105
+ "file": "components/competitor-detail.tsx",
106
+ "purpose": "Complete screen for one competitor: every field, battle card, scan, pause or resume, remove.",
107
+ "operations": [
108
+ "scan_competitor_radar_competitors__competitor_id__scan_post",
109
+ "update_competitor_radar_competitors__competitor_id__patch",
110
+ "delete_competitor_radar_competitors__competitor_id__delete"
111
+ ],
112
+ "tiers": [
113
+ "billable",
114
+ "destructive"
115
+ ]
116
+ },
117
+ {
118
+ "name": "ReportDetail",
119
+ "file": "components/report-detail.tsx",
120
+ "purpose": "Complete screen for one radar report: status, preview, HTML and PDF links, regenerate.",
121
+ "operations": [
122
+ "list_radar_reports_radar_reports_get",
123
+ "create_radar_report_radar_reports_post"
124
+ ],
125
+ "tiers": [
126
+ "read",
127
+ "billable"
128
+ ]
90
129
  }
91
130
  ],
92
131
  "hooks": [
@@ -184,6 +223,7 @@
184
223
  }
185
224
  ],
186
225
  "peerDependencies": {
187
- "react": ">=18"
226
+ "react": ">=18",
227
+ "lucide-react": ">=0.400"
188
228
  }
189
229
  }
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CompetitorDetail: the complete screen for one tracked competitor. Finds it by id (so `?id=` deep links work), shows every
5
+ * field (tier, threat score, traffic and trend, posting cadence, ads live, scan state and budget, capture errors), the battle
6
+ * card, and every action: scan now, pause or resume, remove. A running scan is followed until it finishes.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. listCompetitors GET /radar/competitors (read; re-read every 5 s while a scan runs)
10
+ * 2. competitorBattleCard GET /radar/competitors/{id}/battle-card (read, free; inside CompetitorBattleCard)
11
+ * 3. scanCompetitor POST /radar/competitors/{id}/scan (billable, confirmed: at most budget + 1 credits)
12
+ * 4. updateCompetitor PATCH /radar/competitors/{id} (billable tier, free; confirmed; pause or resume)
13
+ * 5. deleteCompetitor DELETE /radar/competitors/{id} (destructive, confirmed; removes its history too)
14
+ */
15
+ import { useEffect, type ReactNode } 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 { g8Delete, g8Patch, g8Post } from "@/g8/lib/g8-client";
20
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
21
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
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 { useCompetitors } from "../hooks/use-competitors";
26
+ import { formatCount, isScanning, scanCostEstimate } from "../lib/radar-types";
27
+ import { CompetitorBattleCard } from "./competitor-battle-card";
28
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
29
+ import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
30
+
31
+ export interface CompetitorDetailProps {
32
+ competitorId: string;
33
+ onClose?: () => void;
34
+ onExpand?: () => void;
35
+ onCollapse?: () => void;
36
+ onChanged?: () => void;
37
+ /** Extra header actions from the screen's `renderDetailActions` slot. */
38
+ extraActions?: ReactNode;
39
+ /** Extra tabs from the screen's `extraDetailTabs` slot, after the built-in ones. */
40
+ extraTabs?: { id: string; label: string; content: ReactNode }[];
41
+ }
42
+
43
+ export function CompetitorDetail({ competitorId, onClose, onExpand, onCollapse, onChanged, extraActions, extraTabs }: CompetitorDetailProps) {
44
+ const { ask, dialog, failure } = useConfirmAction();
45
+ const { competitors, capacity, loading, error, refetch } = useCompetitors();
46
+ const c = competitors.find((x) => x.id === competitorId);
47
+ const scanning = c ? isScanning(c) : false;
48
+
49
+ useEffect(() => {
50
+ if (!scanning) return;
51
+ const timer = setInterval(() => void refetch(), 5000);
52
+ return () => clearInterval(timer);
53
+ }, [scanning, refetch]);
54
+
55
+ if (error) return <ApiError error={error} title="Could not load the competitor" onRetry={() => void refetch()} />;
56
+ if (loading && competitors.length === 0) return <Skeleton className="h-40 w-full" />;
57
+ if (!c) return <EmptyState title="Competitor not found" description="It may have been removed." />;
58
+ const label = c.name || c.domain;
59
+ const changed = () => {
60
+ void refetch();
61
+ onChanged?.();
62
+ };
63
+ const errors = Object.entries(c.enrichment_errors ?? {});
64
+ const trend = typeof c.traffic_trend_pct === "number" ? `${c.traffic_trend_pct > 0 ? "+" : ""}${Math.round(c.traffic_trend_pct)}%` : "-";
65
+
66
+ const actions = (
67
+ <div className="flex flex-wrap gap-1">
68
+ <AskButton ask={ask} label="Scan now" tier="billable" title={`Scan ${label}`} confirmLabel="Scan" estimatedCredits={scanCostEstimate(c)} disabled={scanning || !c.is_active}
69
+ description={`1 credit per page captured (up to ${c.page_budget}) plus 1 for the traffic lookup. This is the most it can charge; your balance is not checked here.`}
70
+ run={() => g8Post(`/radar/competitors/${c.id}/scan`, {})} onDone={changed} />
71
+ <AskButton ask={ask} label={c.is_active ? "Pause" : "Resume"} variant="ghost" tier="billable" title={`${c.is_active ? "Pause" : "Resume"} ${label}`} confirmLabel={c.is_active ? "Pause" : "Resume"} estimatedCredits={0}
72
+ description={c.is_active ? "A paused competitor is no longer scanned. Its history is kept." : "Scans resume on the normal schedule."}
73
+ run={() => g8Patch(`/radar/competitors/${c.id}`, { is_active: !c.is_active })} onDone={changed} />
74
+ <AskButton ask={ask} label="Remove" variant="ghost" tier="destructive" title={`Remove ${label}`} confirmLabel="Remove" tolerateStatus={[404]}
75
+ description="The competitor and its captured pages, ads and history are deleted." run={() => g8Delete(`/radar/competitors/${c.id}`)} onDone={() => { changed(); onClose?.(); }} />
76
+ </div>
77
+ );
78
+
79
+ const tabs = [
80
+ {
81
+ id: "overview",
82
+ label: "Overview",
83
+ content: (
84
+ <div className="space-y-4">
85
+ <KeyValueList columns={2} items={[
86
+ { label: "Website", value: <a className="underline" href={`https://${c.domain}`} target="_blank" rel="noreferrer">{c.domain}</a> },
87
+ { label: "Tier", value: c.tier === "tier1" ? "Tier 1 (25 pages per scan)" : "Watchlist (8 pages per scan)" },
88
+ { label: "Threat score", value: c.threat_score ?? "-" },
89
+ { label: "Monthly traffic", value: formatCount(c.traffic_estimate) },
90
+ { label: "Traffic trend", value: trend },
91
+ { label: "Posts per week", value: c.posts_per_week ?? "-" },
92
+ { label: "Ads live", value: formatCount(c.ads_live_count) },
93
+ { label: "Page budget", value: c.page_budget },
94
+ { label: "Last scanned", value: c.last_scanned_at ? c.last_scanned_at.slice(0, 10) : "not scanned yet" },
95
+ { label: "Scan state", value: scanning ? "scanning..." : c.scan_status },
96
+ ]} />
97
+ {errors.length > 0 ? (
98
+ <SectionCard title="Capture issues" description="Sources that could not be read on the last scan.">
99
+ <ul className="space-y-1 text-sm">{errors.map(([source, message]) => <li key={source}><span className="font-medium">{source}</span>: {message}</li>)}</ul>
100
+ </SectionCard>
101
+ ) : null}
102
+ {capacity ? <p className="text-xs text-muted-foreground">{capacity.used} of {capacity.limit} competitors tracked on your plan.</p> : null}
103
+ </div>
104
+ ),
105
+ },
106
+ { id: "card", label: "Battle card", content: <CompetitorBattleCard competitorId={c.id} /> },
107
+ ];
108
+ return (
109
+ <>
110
+ <DetailPanel title={label} subtitle={c.domain} avatar={<EntityAvatar name={label} kind="company" size="lg" />}
111
+ badges={<StatusPill tone={c.is_active ? (scanning ? "info" : "positive") : "neutral"}>{!c.is_active ? "paused" : scanning ? "scanning" : "tracking"}</StatusPill>}
112
+ actions={extraActions ? <>{actions}{extraActions}</> : actions} tabs={extraTabs?.length ? [...tabs, ...extraTabs] : tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
113
+ {failure ? <ApiError error={failure} title="That did not work" /> : null}
114
+ <ConfirmSpendDialog {...dialog} />
115
+ </>
116
+ );
117
+ }