@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,280 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Connect graph8 (local preview only): the first-run screen of a created app when the server has no graph8 API
5
+ * key yet, and the place to replace or remove the key later.
6
+ *
7
+ * Calls this app's own `/api/g8-connect` route (g8/server/connect.ts), never graph8 directly:
8
+ * GET status: is a key configured, where from (server environment or the pasted file), whose is it
9
+ * POST { key }: the server checks the key with a free read (GET /me), then stores it in .g8/local-key
10
+ * DELETE remove the stored key
11
+ * No billable, external or destructive graph8 operation (removing the local file only forgets the key here).
12
+ * The key goes from the input to the server once and is never kept in browser state after submit, storage or URL.
13
+ *
14
+ * Exports:
15
+ * - ConnectGraph8Panel: the full-page panel (no required props).
16
+ * - G8ConnectGate: wraps the app; in `next dev` it shows the screen until a key is connected, plus a small
17
+ * "Local preview" button to manage the key. In a production build it renders its children and nothing else.
18
+ */
19
+
20
+ import { useCallback, useEffect, useId, useState, type FormEvent, type ReactNode } from "react";
21
+ import { CheckCircle2, ExternalLink, KeyRound, Loader2, X } from "lucide-react";
22
+ import { Button, Input, cn } from "@graph8/sdk/ui";
23
+ import { DEFAULT_BRAND, brandUrl, useBrand } from "../hooks/use-brand";
24
+
25
+ export const CONNECT_PATH = "/api/g8-connect";
26
+ /** Where API keys are managed, on the platform's web app (white-label: see hooks/use-brand). */
27
+ const KEYS_PATH = "/studio/settings?tab=api";
28
+
29
+ export interface ConnectStatus {
30
+ localPreview: boolean;
31
+ connected: boolean;
32
+ source: "env" | "file" | null;
33
+ keyHint?: string;
34
+ keyFile?: string;
35
+ account?: { name?: string; workspace?: string; scopes?: string[] };
36
+ problem?: string;
37
+ }
38
+
39
+ async function readJson(res: Response): Promise<Record<string, unknown>> {
40
+ return (await res.json().catch(() => ({}))) as Record<string, unknown>;
41
+ }
42
+
43
+ /** GET the status. Null when the route does not exist or refuses (production, not this machine). */
44
+ export async function fetchConnectStatus(): Promise<ConnectStatus | null> {
45
+ try {
46
+ const res = await fetch(CONNECT_PATH, { headers: { accept: "application/json" }, cache: "no-store" });
47
+ if (!res.ok) return null;
48
+ const body = await readJson(res);
49
+ return body.localPreview === true ? (body as unknown as ConnectStatus) : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function accountLine(s: ConnectStatus, platform = DEFAULT_BRAND.linkLabel): string {
56
+ const a = s.account ?? {};
57
+ return [a.name, a.workspace].filter(Boolean).join(" at ") || `a ${platform} key`;
58
+ }
59
+
60
+ export interface ConnectGraph8PanelProps {
61
+ /** Status from a previous GET (the gate passes it); fetched on mount when absent. */
62
+ status?: ConnectStatus | null;
63
+ /** Called after a key is stored or removed, with the new status. */
64
+ onChange?: (status: ConnectStatus) => void;
65
+ className?: string;
66
+ }
67
+
68
+ /** Paste, check and store the server's graph8 API key; replace or remove it. */
69
+ export function ConnectGraph8Panel({ status: initial, onChange, className }: ConnectGraph8PanelProps) {
70
+ const [status, setStatus] = useState<ConnectStatus | null | undefined>(initial);
71
+ const [key, setKey] = useState("");
72
+ const [busy, setBusy] = useState<"save" | "remove" | null>(null);
73
+ const [error, setError] = useState<string | null>(null);
74
+ const [replacing, setReplacing] = useState(false);
75
+ const inputId = useId();
76
+ const brand = useBrand();
77
+
78
+ useEffect(() => {
79
+ if (initial === undefined) void fetchConnectStatus().then(setStatus);
80
+ }, [initial]);
81
+
82
+ const apply = (next: ConnectStatus) => {
83
+ setStatus(next);
84
+ setReplacing(false);
85
+ onChange?.(next);
86
+ };
87
+
88
+ const submit = async (e: FormEvent) => {
89
+ e.preventDefault();
90
+ if (!key.trim() || busy) return;
91
+ setBusy("save");
92
+ setError(null);
93
+ try {
94
+ const res = await fetch(CONNECT_PATH, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key: key.trim() }) });
95
+ const body = await readJson(res);
96
+ setKey("");
97
+ if (!res.ok) setError(typeof body.message === "string" ? body.message : `Could not save the key (HTTP ${res.status}).`);
98
+ else apply(body as unknown as ConnectStatus);
99
+ } catch {
100
+ setError("Could not reach this app's server. Is `npm run dev` still running?");
101
+ } finally {
102
+ setBusy(null);
103
+ }
104
+ };
105
+
106
+ const remove = async () => {
107
+ setBusy("remove");
108
+ setError(null);
109
+ try {
110
+ const res = await fetch(CONNECT_PATH, { method: "DELETE" });
111
+ const body = await readJson(res);
112
+ if (!res.ok) setError(typeof body.message === "string" ? body.message : `Could not remove the key (HTTP ${res.status}).`);
113
+ else apply(body as unknown as ConnectStatus);
114
+ } catch {
115
+ setError("Could not reach this app's server. Is `npm run dev` still running?");
116
+ } finally {
117
+ setBusy(null);
118
+ }
119
+ };
120
+
121
+ const connected = Boolean(status?.connected);
122
+ const fromEnv = status?.source === "env";
123
+ const showForm = !fromEnv && (!status?.source || replacing);
124
+
125
+ return (
126
+ <section aria-labelledby={`${inputId}-title`} className={cn("mx-auto flex w-full max-w-md flex-col gap-5 px-4 py-14", className)}>
127
+ <div className="flex flex-col gap-2">
128
+ <div aria-hidden className="flex size-10 items-center justify-center rounded-lg border border-border bg-muted text-foreground">
129
+ <KeyRound className="size-5" strokeWidth={1.5} />
130
+ </div>
131
+ <h1 id={`${inputId}-title`} className="text-lg font-medium text-foreground">
132
+ {connected ? `Connected to ${brand.linkLabel}` : `Connect ${brand.linkLabel}`}
133
+ </h1>
134
+ <p className="text-sm leading-5 text-text-color-muted">
135
+ This app reads and writes your {brand.linkLabel} data through its own server. Paste an API key once: the server checks
136
+ it and keeps it in <code className="text-xs">{status?.keyFile ?? ".g8/local-key"}</code> on this computer. The
137
+ browser never stores it.
138
+ </p>
139
+ </div>
140
+
141
+ {status?.source ? (
142
+ <div className="flex items-start gap-3 rounded-lg border border-border p-3 text-sm">
143
+ {connected ? (
144
+ <CheckCircle2 aria-hidden className="mt-0.5 size-4 shrink-0 text-positive-foreground" strokeWidth={1.5} />
145
+ ) : (
146
+ <X aria-hidden className="mt-0.5 size-4 shrink-0 text-error-foreground" strokeWidth={1.5} />
147
+ )}
148
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
149
+ <span className="text-foreground">{connected ? accountLine(status, brand.linkLabel) : "The key no longer works"}</span>
150
+ <span className="text-xs text-text-color-muted">
151
+ Key {status.keyHint ?? ""} from {fromEnv ? "the server environment (.env.local)" : status.keyFile ?? ".g8/local-key"}
152
+ {status.problem ? `. ${status.problem}` : ""}
153
+ </span>
154
+ {fromEnv ? <span className="text-xs text-text-color-muted">To change it, edit .env.local and restart the dev server.</span> : null}
155
+ </div>
156
+ </div>
157
+ ) : null}
158
+
159
+ {showForm ? (
160
+ <form onSubmit={submit} className="flex flex-col gap-2">
161
+ <label htmlFor={inputId} className="text-sm font-medium text-foreground">
162
+ {brand.linkLabel} API key
163
+ </label>
164
+ <Input
165
+ id={inputId}
166
+ type="password"
167
+ autoComplete="off"
168
+ spellCheck={false}
169
+ value={key}
170
+ onChange={(e) => setKey(e.target.value)}
171
+ placeholder="Paste the key"
172
+ aria-invalid={error ? true : undefined}
173
+ aria-describedby={error ? `${inputId}-error` : undefined}
174
+ />
175
+ {error ? (
176
+ <p id={`${inputId}-error`} role="alert" className="text-xs text-error-foreground">
177
+ {error}
178
+ </p>
179
+ ) : null}
180
+ <div className="flex items-center gap-2 pt-1">
181
+ <Button type="submit" disabled={!key.trim() || busy !== null}>
182
+ {busy === "save" ? <Loader2 aria-hidden className="size-4 animate-spin" /> : null}
183
+ {busy === "save" ? "Checking" : "Connect"}
184
+ </Button>
185
+ {replacing ? (
186
+ <Button type="button" variant="ghost" onClick={() => setReplacing(false)}>
187
+ Cancel
188
+ </Button>
189
+ ) : null}
190
+ </div>
191
+ </form>
192
+ ) : status?.source === "file" ? (
193
+ <div className="flex flex-col gap-2">
194
+ <div className="flex items-center gap-2">
195
+ <Button type="button" variant="outline" onClick={() => setReplacing(true)} disabled={busy !== null}>
196
+ Replace key
197
+ </Button>
198
+ <Button type="button" variant="ghost" onClick={() => void remove()} disabled={busy !== null}>
199
+ {busy === "remove" ? <Loader2 aria-hidden className="size-4 animate-spin" /> : null}
200
+ Remove key
201
+ </Button>
202
+ </div>
203
+ {error ? (
204
+ <p role="alert" className="text-xs text-error-foreground">
205
+ {error}
206
+ </p>
207
+ ) : null}
208
+ </div>
209
+ ) : null}
210
+
211
+ <a href={brandUrl(brand, KEYS_PATH)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-sm text-foreground underline-offset-4 hover:underline">
212
+ Get a key in {brand.linkLabel} (Settings, API)
213
+ <ExternalLink aria-hidden className="size-3.5" strokeWidth={1.5} />
214
+ </a>
215
+ <p className="text-xs leading-5 text-text-color-muted">
216
+ Local preview: this screen and the data routes only answer requests from this computer while you run the dev
217
+ server. Before you deploy, set <code>authorize</code> in app/api/g8/[...path]/route.ts and the key in your host&apos;s
218
+ environment; <code>npx @graph8/sdk doctor --production</code> checks both.
219
+ </p>
220
+ </section>
221
+ );
222
+ }
223
+
224
+ export interface G8ConnectGateProps {
225
+ children: ReactNode;
226
+ /** Hide the small "Local preview" button once connected. Default false. */
227
+ hideManageButton?: boolean;
228
+ }
229
+
230
+ /**
231
+ * In `next dev`: shows ConnectGraph8Panel instead of the app until a key is connected, and a small button to
232
+ * manage the key afterwards. In a production build (and when /api/g8-connect is missing or refuses) it is
233
+ * exactly its children: no request, no extra markup.
234
+ */
235
+ export function G8ConnectGate({ children, hideManageButton = false }: G8ConnectGateProps) {
236
+ const dev = process.env.NODE_ENV !== "production";
237
+ const [status, setStatus] = useState<ConnectStatus | null | undefined>(dev ? undefined : null);
238
+ const [managing, setManaging] = useState(false);
239
+ const brand = useBrand();
240
+
241
+ useEffect(() => {
242
+ if (dev) void fetchConnectStatus().then(setStatus);
243
+ }, [dev]);
244
+
245
+ const changed = useCallback((next: ConnectStatus) => {
246
+ setStatus(next);
247
+ // Every screen loaded its data with the old (or no) key: start them again.
248
+ if (next.connected) window.location.reload();
249
+ }, []);
250
+
251
+ if (!status) return <>{children}</>;
252
+ if (!status.connected || managing) {
253
+ return (
254
+ <div className="relative">
255
+ {managing && status.connected ? (
256
+ <Button type="button" variant="ghost" size="sm" className="absolute right-4 top-4" onClick={() => setManaging(false)}>
257
+ Back to the app
258
+ </Button>
259
+ ) : null}
260
+ <ConnectGraph8Panel status={status} onChange={changed} />
261
+ </div>
262
+ );
263
+ }
264
+ return (
265
+ <>
266
+ {children}
267
+ {hideManageButton ? null : (
268
+ <button
269
+ type="button"
270
+ onClick={() => setManaging(true)}
271
+ title={`Local preview, connected as ${accountLine(status, brand.linkLabel)}. Manage the ${brand.linkLabel} key.`}
272
+ className="fixed bottom-3 right-3 z-40 inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2 text-xs text-text-color-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
273
+ >
274
+ <KeyRound aria-hidden className="size-3.5" strokeWidth={1.5} />
275
+ Local preview
276
+ </button>
277
+ )}
278
+ </>
279
+ );
280
+ }
@@ -12,6 +12,7 @@ export * from "./entity-avatar";
12
12
  export * from "./form-sheet";
13
13
  export * from "./key-value-list";
14
14
  export * from "./screen-layout";
15
+ export * from "./record-link";
15
16
  export * from "./section-card";
16
17
  export * from "./sparkline";
17
18
  export * from "./split-view";
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RecordLink: a record's name as a real `<a href>`. A plain click runs `onOpen` (the screen opens the record
5
+ * the way it always did: its own URL state or component state); Cmd/Ctrl/Shift/Alt-click and middle-click fall
6
+ * through to the browser, so "open in a new tab" lands on the same screen with the record open. The href is
7
+ * `?<param>=<id>`, where `param` is the URL param the OWNING screen reads for this record type. Without a `param`
8
+ * there is no honest href (a new tab would open nothing, or the wrong record), so it renders a plain button that
9
+ * only runs `onOpen`. The href is relative (SSR-safe) and is completed with the current query when the pointer or
10
+ * focus reaches it, so Cmd-click keeps `tab`, `pipeline` and the rest. Add `full` for `view=full`. Only a screen
11
+ * without an `onOpen` of its own should pass `writeUrl`, which makes a plain click write the param itself.
12
+ *
13
+ * SDK operations: none.
14
+ */
15
+ import type { MouseEvent, ReactNode, SyntheticEvent } from "react";
16
+ import { cn } from "@graph8/sdk/ui";
17
+ import { writeUrlParam } from "../../hooks/use-url-state";
18
+
19
+ export interface RecordLinkProps {
20
+ /** The record id written to the URL param. */
21
+ id: string | number;
22
+ /** URL param the owning screen reads for this record. Omit it when the screen reads none: a button is rendered. */
23
+ param?: string;
24
+ /** Also set `view=full` (the record as a full page). */
25
+ full?: boolean;
26
+ /** Runs on a plain click: open the record the way the screen does (its URL setter or component state). */
27
+ onOpen?: () => void;
28
+ /** A plain click also writes `param` (and `view`) to the URL itself. Default false: `onOpen` owns the state. */
29
+ writeUrl?: boolean;
30
+ className?: string;
31
+ children: ReactNode;
32
+ }
33
+
34
+ /** Relative href that opens `id`: `?param=id[&view=full]`. Depends on nothing but its arguments, so server and client agree. */
35
+ export function recordHref(id: string | number, param: string, full = false): string {
36
+ return `?${param}=${encodeURIComponent(String(id))}${full ? "&view=full" : ""}`;
37
+ }
38
+
39
+ /** The same href merged into the page's current query (call it at event time, never during render). */
40
+ export function currentRecordHref(id: string | number, param: string, full = false): string {
41
+ const url = new URL(window.location.href);
42
+ url.searchParams.set(param, String(id));
43
+ if (full) url.searchParams.set("view", "full");
44
+ else url.searchParams.delete("view");
45
+ return `${url.pathname}${url.search}${url.hash}`;
46
+ }
47
+
48
+ export function RecordLink({ id, param, full = false, onOpen, writeUrl = false, className, children }: RecordLinkProps) {
49
+ const key = param ?? (writeUrl ? "id" : undefined);
50
+ const cls = cn("font-medium text-foreground underline-offset-2 hover:underline focus-visible:underline", className);
51
+ if (!key) {
52
+ return (
53
+ <button type="button" className={cn(cls, "text-left")} onClick={(e) => { e.stopPropagation(); onOpen?.(); }}>
54
+ {children}
55
+ </button>
56
+ );
57
+ }
58
+ const onClick = (e: MouseEvent<HTMLAnchorElement>): void => {
59
+ e.stopPropagation(); // the row around it also opens the record: do not do it twice
60
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
61
+ if (!onOpen && !writeUrl) return; // nothing to do in place: let the browser follow the href
62
+ e.preventDefault();
63
+ if (writeUrl) {
64
+ writeUrlParam("view", full ? "full" : null);
65
+ writeUrlParam(key, String(id), { push: true });
66
+ }
67
+ onOpen?.();
68
+ };
69
+ /** Complete the href with the current query just before the browser can use it (hover, press, focus, context menu). */
70
+ const complete = (e: SyntheticEvent<HTMLAnchorElement>): void => e.currentTarget.setAttribute("href", currentRecordHref(id, key, full));
71
+ return (
72
+ <a href={recordHref(id, key, full)} onClick={onClick} onMouseEnter={complete} onMouseDown={complete} onFocus={complete} onContextMenu={complete} onTouchStart={complete} className={cls}>
73
+ {children}
74
+ </a>
75
+ );
76
+ }
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The recharts half of TimeSeriesChart, in its own module so it is code-split: `time-series-chart.tsx`
5
+ * loads it on demand (React.lazy), and pages without a chart never download recharts. Import this file
6
+ * directly only if you need the chart rendered synchronously (it then joins your page bundle).
7
+ *
8
+ * SDK operations: none. Peer: recharts.
9
+ */
10
+ import { useMemo } from "react";
11
+ import { Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
12
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, cn, type ChartConfig } from "@graph8/sdk/ui";
13
+ import { TONE_VAR, defaultFormatX, defaultFormatY } from "./time-series-format";
14
+ import type { TimeSeries, TimeSeriesChartProps } from "./time-series-format";
15
+
16
+ export function TimeSeriesChartRecharts({ data, xKey, series, kind = "line", height = 220, formatX = defaultFormatX, formatY = defaultFormatY, stacked, className }: TimeSeriesChartProps) {
17
+ const config = useMemo<ChartConfig>(
18
+ () => Object.fromEntries(series.map((s) => [s.key, { label: s.label ?? s.key, color: TONE_VAR[s.tone ?? "ink"] }])),
19
+ [series],
20
+ );
21
+ const axes = (
22
+ <>
23
+ <CartesianGrid vertical={false} strokeDasharray="0" stroke="var(--border)" strokeOpacity={0.9} />
24
+ <XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} tickFormatter={formatX} tick={{ fontSize: 11 }} />
25
+ <YAxis width={36} tickLine={false} axisLine={false} tickMargin={4} tickFormatter={formatY} tick={{ fontSize: 11 }} allowDecimals={false} />
26
+ <ChartTooltip cursor={{ stroke: "var(--border)" }} content={<ChartTooltipContent labelFormatter={(v) => formatX(v)} formatter={(v, name) => [`${formatY(Number(v))} `, String(config[String(name)]?.label ?? name)]} indicator="line" />} />
27
+ </>
28
+ );
29
+ const stroke = (s: TimeSeries) => ({
30
+ stroke: `var(--color-${s.key})`,
31
+ strokeWidth: s.tone === "prior" ? 1.25 : 1.5,
32
+ strokeDasharray: s.tone === "prior" ? "4 3" : undefined,
33
+ dot: false as const,
34
+ activeDot: { r: 3 },
35
+ isAnimationActive: false,
36
+ });
37
+ const style = { height } as const;
38
+
39
+ return (
40
+ <ChartContainer config={config} className={cn("aspect-auto w-full text-text-color-muted", className)} style={style} data-testid="time-series-chart">
41
+ {kind === "bar" ? (
42
+ <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }} barCategoryGap="25%">
43
+ {axes}
44
+ {series.map((s) => (
45
+ <Bar key={s.key} dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone ? 0.85 : 1} radius={[2, 2, 0, 0]} isAnimationActive={false} />
46
+ ))}
47
+ </BarChart>
48
+ ) : kind === "area" ? (
49
+ <AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
50
+ {axes}
51
+ {series.map((s) => (
52
+ <Area key={s.key} type="monotone" dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone === "prior" ? 0 : 0.06} {...stroke(s)} />
53
+ ))}
54
+ </AreaChart>
55
+ ) : (
56
+ <LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
57
+ {axes}
58
+ {series.map((s) => (
59
+ <Line key={s.key} type="monotone" dataKey={s.key} name={s.key} {...stroke(s)} />
60
+ ))}
61
+ </LineChart>
62
+ )}
63
+ </ChartContainer>
64
+ );
65
+ }
@@ -6,109 +6,27 @@
6
6
  * A `prior` series is the dashed warm-grey comparison. Chrome is recessive: hairline gridlines, muted
7
7
  * axis labels, no borders. Built on recharts through the UI kit's ChartContainer.
8
8
  *
9
+ * recharts is code-split: the chart module (time-series-chart-recharts.tsx) loads on first render, with a
10
+ * same-height skeleton meanwhile, so a page that shows no chart never downloads it. Same props as before.
11
+ *
9
12
  * SDK operations: none. Peer: recharts.
10
13
  */
11
- import { useMemo } from "react";
12
- import { Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
13
- import { ChartContainer, ChartTooltip, ChartTooltipContent, cn, type ChartConfig } from "@graph8/sdk/ui";
14
- import type { Tone } from "./status-pill";
15
-
16
- export interface TimeSeries {
17
- /** The field of each row to plot. */
18
- key: string;
19
- label?: string;
20
- /** Ink unless the direction means good or bad. "prior" is the dashed comparison line. */
21
- tone?: Tone | "prior";
22
- }
23
-
24
- export interface TimeSeriesChartProps {
25
- data: Array<Record<string, unknown>>;
26
- /** Field holding the x value (an ISO date, a label). */
27
- xKey: string;
28
- series: TimeSeries[];
29
- kind?: "line" | "area" | "bar";
30
- height?: number;
31
- /** Format an x value for the axis ("2026-10-01" -> "Oct 1"). */
32
- formatX?: (value: unknown) => string;
33
- /** Format a y value for the axis and the tooltip. */
34
- formatY?: (value: number) => string;
35
- /** Stack bars/areas. */
36
- stacked?: boolean;
37
- className?: string;
38
- }
14
+ import { Suspense, lazy } from "react";
15
+ import { Skeleton, cn } from "@graph8/sdk/ui";
16
+ import type { TimeSeriesChartProps } from "./time-series-format";
39
17
 
40
- const TONE_VAR: Record<NonNullable<TimeSeries["tone"]> | "ink", string> = {
41
- ink: "var(--foreground)",
42
- neutral: "var(--text-color-muted)",
43
- info: "var(--info-foreground)",
44
- positive: "var(--positive-foreground)",
45
- warning: "var(--warning-foreground)",
46
- negative: "var(--error-foreground)",
47
- prior: "var(--text-color-muted)",
48
- };
18
+ export type { TimeSeries, TimeSeriesChartProps } from "./time-series-format";
19
+ export { defaultFormatX, defaultFormatY } from "./time-series-format";
49
20
 
50
- export function defaultFormatX(value: unknown): string {
51
- if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
52
- const d = new Date(value.length === 10 ? `${value}T00:00:00Z` : value);
53
- if (!Number.isNaN(d.getTime())) return d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: value.length === 10 ? "UTC" : undefined });
54
- }
55
- return String(value ?? "");
56
- }
57
-
58
- export function defaultFormatY(value: number): string {
59
- if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
60
- if (Math.abs(value) >= 10_000) return `${(value / 1_000).toFixed(0)}k`;
61
- if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
62
- return value.toLocaleString();
63
- }
21
+ const Recharts = lazy(() => import("./time-series-chart-recharts").then((m) => ({ default: m.TimeSeriesChartRecharts })));
64
22
 
65
- export function TimeSeriesChart({ data, xKey, series, kind = "line", height = 220, formatX = defaultFormatX, formatY = defaultFormatY, stacked, className }: TimeSeriesChartProps) {
66
- const config = useMemo<ChartConfig>(
67
- () => Object.fromEntries(series.map((s) => [s.key, { label: s.label ?? s.key, color: TONE_VAR[s.tone ?? "ink"] }])),
68
- [series],
23
+ export function TimeSeriesChart(props: TimeSeriesChartProps) {
24
+ const fallback = (
25
+ <Skeleton className={cn("w-full", props.className)} style={{ height: props.height ?? 220 }} aria-label="Loading chart" data-testid="time-series-chart-loading" />
69
26
  );
70
- const axes = (
71
- <>
72
- <CartesianGrid vertical={false} strokeDasharray="0" stroke="var(--border)" strokeOpacity={0.9} />
73
- <XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} tickFormatter={formatX} tick={{ fontSize: 11 }} />
74
- <YAxis width={36} tickLine={false} axisLine={false} tickMargin={4} tickFormatter={formatY} tick={{ fontSize: 11 }} allowDecimals={false} />
75
- <ChartTooltip cursor={{ stroke: "var(--border)" }} content={<ChartTooltipContent labelFormatter={(v) => formatX(v)} formatter={(v, name) => [`${formatY(Number(v))} `, String(config[String(name)]?.label ?? name)]} indicator="line" />} />
76
- </>
77
- );
78
- const stroke = (s: TimeSeries) => ({
79
- stroke: `var(--color-${s.key})`,
80
- strokeWidth: s.tone === "prior" ? 1.25 : 1.5,
81
- strokeDasharray: s.tone === "prior" ? "4 3" : undefined,
82
- dot: false as const,
83
- activeDot: { r: 3 },
84
- isAnimationActive: false,
85
- });
86
- const style = { height } as const;
87
-
88
27
  return (
89
- <ChartContainer config={config} className={cn("aspect-auto w-full text-text-color-muted", className)} style={style} data-testid="time-series-chart">
90
- {kind === "bar" ? (
91
- <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }} barCategoryGap="25%">
92
- {axes}
93
- {series.map((s) => (
94
- <Bar key={s.key} dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone ? 0.85 : 1} radius={[2, 2, 0, 0]} isAnimationActive={false} />
95
- ))}
96
- </BarChart>
97
- ) : kind === "area" ? (
98
- <AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
99
- {axes}
100
- {series.map((s) => (
101
- <Area key={s.key} type="monotone" dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone === "prior" ? 0 : 0.06} {...stroke(s)} />
102
- ))}
103
- </AreaChart>
104
- ) : (
105
- <LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
106
- {axes}
107
- {series.map((s) => (
108
- <Line key={s.key} type="monotone" dataKey={s.key} name={s.key} {...stroke(s)} />
109
- ))}
110
- </LineChart>
111
- )}
112
- </ChartContainer>
28
+ <Suspense fallback={fallback}>
29
+ <Recharts {...props} />
30
+ </Suspense>
113
31
  );
114
32
  }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Types and formatters shared by TimeSeriesChart and its lazily loaded recharts implementation.
5
+ * No recharts here, so importing them costs nothing.
6
+ */
7
+ import type { Tone } from "./status-pill";
8
+
9
+ export interface TimeSeries {
10
+ /** The field of each row to plot. */
11
+ key: string;
12
+ label?: string;
13
+ /** Ink unless the direction means good or bad. "prior" is the dashed comparison line. */
14
+ tone?: Tone | "prior";
15
+ }
16
+
17
+ export interface TimeSeriesChartProps {
18
+ data: Array<Record<string, unknown>>;
19
+ /** Field holding the x value (an ISO date, a label). */
20
+ xKey: string;
21
+ series: TimeSeries[];
22
+ kind?: "line" | "area" | "bar";
23
+ height?: number;
24
+ /** Format an x value for the axis ("2026-10-01" -> "Oct 1"). */
25
+ formatX?: (value: unknown) => string;
26
+ /** Format a y value for the axis and the tooltip. */
27
+ formatY?: (value: number) => string;
28
+ /** Stack bars/areas. */
29
+ stacked?: boolean;
30
+ className?: string;
31
+ }
32
+
33
+ export const TONE_VAR: Record<NonNullable<TimeSeries["tone"]> | "ink", string> = {
34
+ ink: "var(--foreground)",
35
+ neutral: "var(--text-color-muted)",
36
+ info: "var(--info-foreground)",
37
+ positive: "var(--positive-foreground)",
38
+ warning: "var(--warning-foreground)",
39
+ negative: "var(--error-foreground)",
40
+ prior: "var(--text-color-muted)",
41
+ };
42
+
43
+ export function defaultFormatX(value: unknown): string {
44
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
45
+ const d = new Date(value.length === 10 ? `${value}T00:00:00Z` : value);
46
+ if (!Number.isNaN(d.getTime())) return d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: value.length === 10 ? "UTC" : undefined });
47
+ }
48
+ return String(value ?? "");
49
+ }
50
+
51
+ export function defaultFormatY(value: number): string {
52
+ if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
53
+ if (Math.abs(value) >= 10_000) return `${(value / 1_000).toFixed(0)}k`;
54
+ if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
55
+ return value.toLocaleString();
56
+ }
57
+
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ /**
4
+ * White-label: who the app is and what the platform behind it is called. Every block reads the platform's name
5
+ * and link-outs from here ("Connect in graph8", "Open in graph8", the settings pages they open), so an agency or
6
+ * reseller can show its own name and its own app URL everywhere, from one config (`g8.config.ts` in a created app,
7
+ * passed to <BrandProvider>).
8
+ *
9
+ * const brand = useBrand(); // { name, appUrl, linkLabel, logo? }
10
+ * brandUrl(brand, "/studio/settings?tab=integrations")
11
+ *
12
+ * With no provider, or for any field left out, the default is graph8 itself (app.graph8.com), so nothing changes
13
+ * for an app that does not brand.
14
+ */
15
+
16
+ import { createContext, useContext } from "react";
17
+
18
+ export interface BrandConfig {
19
+ /** The app's own name (sidebar, page title). */
20
+ name: string;
21
+ /** Base URL of the platform's web app; link-outs append their path. No trailing slash needed. */
22
+ appUrl: string;
23
+ /** What the platform is called in copy and links ("Connect in <linkLabel>"). */
24
+ linkLabel: string;
25
+ /** Public path or URL of the logo (`/brand/logo.svg`). */
26
+ logo?: string;
27
+ }
28
+
29
+ export const DEFAULT_BRAND: BrandConfig = { name: "graph8 app", appUrl: "https://app.graph8.com", linkLabel: "graph8" };
30
+
31
+ /** Fill the gaps of a partial config with the defaults; an appUrl that is not http(s) falls back to the default. */
32
+ export function resolveBrand(partial?: Partial<BrandConfig> | null): BrandConfig {
33
+ const p = partial ?? {};
34
+ const text = (v: unknown, d: string) => (typeof v === "string" && v.trim() ? v.trim() : d);
35
+ let appUrl = DEFAULT_BRAND.appUrl;
36
+ if (typeof p.appUrl === "string" && /^https?:\/\/[^\s/]+/i.test(p.appUrl.trim())) appUrl = p.appUrl.trim().replace(/\/+$/, "");
37
+ const logo = typeof p.logo === "string" && p.logo.trim() ? p.logo.trim() : undefined;
38
+ return { name: text(p.name, DEFAULT_BRAND.name), appUrl, linkLabel: text(p.linkLabel, DEFAULT_BRAND.linkLabel), ...(logo ? { logo } : {}) };
39
+ }
40
+
41
+ export const BrandContext = createContext<BrandConfig>(DEFAULT_BRAND);
42
+
43
+ /** The current brand: the nearest <BrandProvider>'s, else graph8's defaults. */
44
+ export function useBrand(): BrandConfig {
45
+ return useContext(BrandContext);
46
+ }
47
+
48
+ /** `appUrl` + a path on the platform (`"/lists"`, `"/studio/settings?tab=api"`); no path gives the app's home. */
49
+ export function brandUrl(brand: Pick<BrandConfig, "appUrl">, path = ""): string {
50
+ if (!path) return brand.appUrl;
51
+ return `${brand.appUrl}${path.startsWith("/") || path.startsWith("?") ? "" : "/"}${path}`;
52
+ }