@graph8/sdk 0.373.0 → 0.374.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 (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PagesTab: how pages perform, and the detail of any one page.
5
+ * Top pages views, unique visitors, average time on page and bounce rate (re-sortable)
6
+ * Landing pages where sessions start
7
+ * Exit pages where sessions end
8
+ * Events custom tracked events (name, how often, how many people, last seen)
9
+ * Page detail a side panel for the page you click: views, unique visitors, form submissions and bookings
10
+ *
11
+ * The page you open is `?page=/path` in the URL. A path the API would refuse (it must start with / and use plain
12
+ * URL characters) is shown but not opened. When the app passes `resolveLandingPage`, a page whose address exactly
13
+ * matches one landing page gets an "Open landing page" button (by landing page id only, never by title).
14
+ *
15
+ * SDK operations, in order (all read, free). RankedTable renders a FirstNotice ("Showing the first N") when a read came back full:
16
+ * 1. getTopPages GET /analytics/behavior/pages (limit 200)
17
+ * 2. getLandingPages GET /analytics/behavior/landing-pages (limit 200)
18
+ * 3. getExitPages GET /analytics/behavior/exit-pages (limit 200)
19
+ * 4. getEvents GET /analytics/behavior/events (limit 200)
20
+ * 5. getPageMetrics GET /analytics/behavior/page-metrics?path= (the open page)
21
+ */
22
+ import type { ReactNode } from "react";
23
+ import { Button } from "@graph8/sdk/ui";
24
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
25
+ import { SplitView } from "@/g8/components/screen/split-view";
26
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
27
+ import { useUrlState } from "@/g8/hooks/use-url-state";
28
+ import { g8Get } from "@/g8/lib/g8-client";
29
+ import { useReport } from "../hooks/use-report";
30
+ import { LIMITS, windowedQuery } from "../lib/analytics-scope";
31
+ import type { ScopeState } from "../lib/analytics-scope";
32
+ import { isReportablePath } from "../lib/page-path";
33
+ import { fmtDateTime, fmtInt, fmtPct, fmtSeconds, num, readRow, readRows, str } from "../lib/web-analytics-types";
34
+ import type { EventRow, ExitRow, LandingRow, PageMetrics, PageRow } from "../lib/web-analytics-types";
35
+ import { RankedTable } from "./ranked-table";
36
+ import { ReportCard } from "./report-card";
37
+
38
+ export interface PagesTabProps {
39
+ scope: ScopeState;
40
+ /** The id of the landing page whose address is exactly this path, if there is exactly one. */
41
+ resolveLandingPage?: (path: string) => string | undefined;
42
+ onOpenLandingPage?: (id: string) => void;
43
+ }
44
+
45
+ function PathCell({ path, onOpen, landing }: { path: string; onOpen: (p: string) => void; landing?: ReactNode }) {
46
+ if (!path) return <span className="text-text-color-muted">Unknown</span>;
47
+ return (
48
+ <span className="flex min-w-0 items-center gap-2">
49
+ {isReportablePath(path) ? (
50
+ <button type="button" className="min-w-0 truncate text-left hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" title={path} aria-label={`Open page ${path}`} onClick={() => onOpen(path)}>{path}</button>
51
+ ) : (
52
+ <span className="min-w-0 truncate" title={path}>{path}</span>
53
+ )}
54
+ {landing}
55
+ </span>
56
+ );
57
+ }
58
+
59
+ function PageDetail({ scope, path, onClose, landingId, onOpenLandingPage }: { scope: ScopeState; path: string; onClose: () => void; landingId?: string; onOpenLandingPage?: (id: string) => void }) {
60
+ const q = windowedQuery(scope);
61
+ const ok = isReportablePath(path);
62
+ const report = useReport(async (signal) => readRow<PageMetrics>(await g8Get("/analytics/behavior/page-metrics", { ...q, path }, { signal })), `${scope.key}|${path}`, { enabled: ok });
63
+ const open = landingId && onOpenLandingPage ? <Button type="button" size="sm" variant="outline" onClick={() => onOpenLandingPage(landingId)}>Open landing page</Button> : null;
64
+ return (
65
+ <DetailPanel title={path} subtitle="Page detail" onClose={onClose} actions={open}>
66
+ {ok ? (
67
+ <ReportCard title="This window" report={report} isEmpty={(d) => d === null} emptyTitle="No activity on this page">
68
+ {(d) => (
69
+ <StatStrip columns={2} items={[
70
+ { label: "Views", value: fmtInt(d!.views) },
71
+ { label: "Unique visitors", value: fmtInt(d!.unique_visitors) },
72
+ { label: "Form submissions", value: fmtInt(d!.form_submissions) },
73
+ { label: "Meetings booked", value: fmtInt(d!.bookings) },
74
+ ]} />
75
+ )}
76
+ </ReportCard>
77
+ ) : (
78
+ <p role="status" className="text-sm text-text-color-muted">This path has characters the report cannot look up, so its detail is not available.</p>
79
+ )}
80
+ </DetailPanel>
81
+ );
82
+ }
83
+
84
+ export function PagesTab({ scope, resolveLandingPage, onOpenLandingPage }: PagesTabProps) {
85
+ const q = windowedQuery(scope);
86
+ const [page, setPage] = useUrlState("page", "");
87
+ const top = useReport(async (signal) => readRows<PageRow>(await g8Get("/analytics/behavior/pages", { ...q, limit: LIMITS.topPages }, { signal })), scope.key);
88
+ const landing = useReport(async (signal) => readRows<LandingRow>(await g8Get("/analytics/behavior/landing-pages", { ...q, limit: LIMITS.landing }, { signal })), scope.key);
89
+ const exits = useReport(async (signal) => readRows<ExitRow>(await g8Get("/analytics/behavior/exit-pages", { ...q, limit: LIMITS.exit }, { signal })), scope.key);
90
+ const events = useReport(async (signal) => readRows<EventRow>(await g8Get("/analytics/behavior/events", { ...q, limit: LIMITS.events }, { signal })), scope.key);
91
+ const none = (rows: unknown[]) => rows.length === 0;
92
+ const landingBtn = (path: string) => {
93
+ const id = resolveLandingPage?.(path);
94
+ return id && onOpenLandingPage ? <button type="button" className="shrink-0 text-xs text-text-color-muted underline hover:text-foreground" aria-label={`Open landing page for ${path}`} onClick={() => onOpenLandingPage(id)}>Landing page</button> : null;
95
+ };
96
+ const list = (
97
+ <div className="space-y-4">
98
+ <ReportCard title="Top pages" report={top} isEmpty={none}>
99
+ {(rows) => (
100
+ <RankedTable rows={rows} noun="pages" limit={LIMITS.topPages} defaultSort="views" rowKey={(r, i) => `${str(r.page_path)}|${i}`} columns={[
101
+ { key: "path", label: "Page", render: (r) => <PathCell path={str(r.page_path)} onOpen={setPage} landing={landingBtn(str(r.page_path))} />, sortBy: (r) => str(r.page_path) },
102
+ { key: "views", label: "Views", numeric: true, render: (r) => fmtInt(r.views), sortBy: (r) => num(r.views) },
103
+ { key: "visitors", label: "Visitors", numeric: true, render: (r) => fmtInt(r.unique_visitors), sortBy: (r) => num(r.unique_visitors) },
104
+ { key: "time", label: "Avg time", numeric: true, render: (r) => fmtSeconds(r.avg_time_on_page), sortBy: (r) => num(r.avg_time_on_page) },
105
+ { key: "bounce", label: "Bounce rate", numeric: true, render: (r) => fmtPct(r.bounce_rate), sortBy: (r) => num(r.bounce_rate) },
106
+ ]} />
107
+ )}
108
+ </ReportCard>
109
+ <ReportCard title="Landing pages" description="Where sessions start" report={landing} isEmpty={none}>
110
+ {(rows) => (
111
+ <RankedTable rows={rows} noun="landing pages" limit={LIMITS.landing} defaultSort="sessions" rowKey={(r, i) => `${str(r.landing_page)}|${i}`} columns={[
112
+ { key: "path", label: "Page", render: (r) => <PathCell path={str(r.landing_page)} onOpen={setPage} landing={landingBtn(str(r.landing_page))} />, sortBy: (r) => str(r.landing_page) },
113
+ { key: "sessions", label: "Sessions", numeric: true, render: (r) => fmtInt(r.sessions), sortBy: (r) => num(r.sessions) },
114
+ { key: "users", label: "Visitors", numeric: true, render: (r) => fmtInt(r.users), sortBy: (r) => num(r.users) },
115
+ ]} />
116
+ )}
117
+ </ReportCard>
118
+ <ReportCard title="Exit pages" description="Where sessions end" report={exits} isEmpty={none}>
119
+ {(rows) => (
120
+ <RankedTable rows={rows} noun="exit pages" limit={LIMITS.exit} defaultSort="exits" rowKey={(r, i) => `${str(r.exit_page)}|${i}`} columns={[
121
+ { key: "path", label: "Page", render: (r) => <PathCell path={str(r.exit_page)} onOpen={setPage} />, sortBy: (r) => str(r.exit_page) },
122
+ { key: "exits", label: "Exits", numeric: true, render: (r) => fmtInt(r.exits), sortBy: (r) => num(r.exits) },
123
+ { key: "users", label: "Visitors", numeric: true, render: (r) => fmtInt(r.users), sortBy: (r) => num(r.users) },
124
+ ]} />
125
+ )}
126
+ </ReportCard>
127
+ <ReportCard title="Events" description="Custom events your site sends" report={events} isEmpty={none} emptyTitle="No custom events in this window">
128
+ {(rows) => (
129
+ <RankedTable rows={rows} noun="events" limit={LIMITS.events} defaultSort="total" rowKey={(r, i) => `${str(r.event_name)}|${i}`} columns={[
130
+ { key: "name", label: "Event", render: (r) => str(r.event_name) || "Unknown", sortBy: (r) => str(r.event_name) },
131
+ { key: "total", label: "Count", numeric: true, render: (r) => fmtInt(r.total_events), sortBy: (r) => num(r.total_events) },
132
+ { key: "users", label: "People", numeric: true, render: (r) => fmtInt(r.unique_users), sortBy: (r) => num(r.unique_users) },
133
+ { key: "last", label: "Last seen", render: (r) => fmtDateTime(r.last_seen), sortBy: (r) => str(r.last_seen) || undefined },
134
+ ]} />
135
+ )}
136
+ </ReportCard>
137
+ </div>
138
+ );
139
+ return (
140
+ <SplitView
141
+ list={list}
142
+ detailOpen={page !== ""}
143
+ onCloseDetail={() => setPage(null)}
144
+ detailTitle="Page detail"
145
+ detail={page ? <PageDetail scope={scope} path={page} onClose={() => setPage(null)} landingId={resolveLandingPage?.(page)} onOpenLandingPage={onOpenLandingPage} /> : undefined}
146
+ />
147
+ );
148
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RankedTable: the table every "top N" report uses. Rows are sorted first (click a header; the default sort is given),
5
+ * then cut to the first `initial` rows with a "Show all N" control, so a sort always ranks the whole list and never just
6
+ * the visible part. A read that came back full (`limit` rows) says "Showing the first N" because the API has no next page
7
+ * for these reports. A value that is unknown is a dash and sorts last.
8
+ *
9
+ * SDK operations: none.
10
+ */
11
+ import { useMemo, useState, type ReactNode } from "react";
12
+ import { ArrowDown, ArrowUp } from "lucide-react";
13
+ import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn } from "@graph8/sdk/ui";
14
+ import { FirstNotice } from "@/g8/components/paging-notices";
15
+
16
+ export interface RankedColumn<R> {
17
+ key: string;
18
+ label: string;
19
+ /** Right-align numbers. */
20
+ numeric?: boolean;
21
+ /** What the cell shows. */
22
+ render: (row: R) => ReactNode;
23
+ /** The value that orders this column (omit and the column does not sort). */
24
+ sortBy?: (row: R) => number | string | undefined;
25
+ }
26
+
27
+ export interface RankedTableProps<R> {
28
+ rows: R[];
29
+ columns: RankedColumn<R>[];
30
+ rowKey: (row: R, index: number) => string;
31
+ /** Column key to sort by first (descending). */
32
+ defaultSort: string;
33
+ /** The `limit` the read was sent with, for the first-N notice. */
34
+ limit?: number;
35
+ noun: string;
36
+ initial?: number;
37
+ caption?: string;
38
+ }
39
+
40
+ function compare(a: number | string | undefined, b: number | string | undefined, dir: 1 | -1): number {
41
+ if (a === undefined && b === undefined) return 0;
42
+ if (a === undefined) return 1;
43
+ if (b === undefined) return -1;
44
+ if (typeof a === "number" && typeof b === "number") return (a - b) * dir;
45
+ return String(a).localeCompare(String(b)) * dir;
46
+ }
47
+
48
+ export function RankedTable<R>({ rows, columns, rowKey, defaultSort, limit, noun, initial = 10, caption }: RankedTableProps<R>) {
49
+ const [sort, setSort] = useState<{ key: string; dir: 1 | -1 }>({ key: defaultSort, dir: -1 });
50
+ const [all, setAll] = useState(false);
51
+ const sorted = useMemo(() => {
52
+ const col = columns.find((c) => c.key === sort.key);
53
+ if (!col?.sortBy) return rows;
54
+ const by = col.sortBy;
55
+ return [...rows].sort((x, y) => compare(by(x), by(y), sort.dir));
56
+ }, [rows, columns, sort]);
57
+ const shown = all ? sorted : sorted.slice(0, initial);
58
+ const click = (c: RankedColumn<R>) => {
59
+ if (!c.sortBy) return;
60
+ setSort((s) => (s.key === c.key ? { key: c.key, dir: s.dir === -1 ? 1 : -1 } : { key: c.key, dir: typeof c.sortBy!(rows[0] as R) === "string" ? 1 : -1 }));
61
+ };
62
+ return (
63
+ <div className="space-y-2">
64
+ <Table aria-label={caption ?? noun}>
65
+ <TableHeader>
66
+ <TableRow>
67
+ {columns.map((c) => {
68
+ const active = sort.key === c.key;
69
+ return (
70
+ <TableHead key={c.key} className={cn(c.numeric && "text-right")} aria-sort={active ? (sort.dir === -1 ? "descending" : "ascending") : undefined}>
71
+ {c.sortBy ? (
72
+ <button type="button" onClick={() => click(c)} className={cn("inline-flex items-center gap-1 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", c.numeric && "flex-row-reverse")}>
73
+ {c.label}
74
+ {active ? (sort.dir === -1 ? <ArrowDown className="size-3" aria-hidden /> : <ArrowUp className="size-3" aria-hidden />) : null}
75
+ </button>
76
+ ) : (
77
+ c.label
78
+ )}
79
+ </TableHead>
80
+ );
81
+ })}
82
+ </TableRow>
83
+ </TableHeader>
84
+ <TableBody>
85
+ {shown.map((r, i) => (
86
+ <TableRow key={rowKey(r, i)}>
87
+ {columns.map((c) => (
88
+ <TableCell key={c.key} className={cn(c.numeric && "text-right tabular-nums")}>{c.render(r)}</TableCell>
89
+ ))}
90
+ </TableRow>
91
+ ))}
92
+ </TableBody>
93
+ </Table>
94
+ {sorted.length > initial ? (
95
+ <div className="flex items-center justify-between text-xs text-text-color-muted">
96
+ <span role="status">Showing {shown.length.toLocaleString()} of {sorted.length.toLocaleString()} {noun}</span>
97
+ <Button type="button" size="sm" variant="outline" onClick={() => setAll((v) => !v)}>{all ? `Show top ${initial}` : `Show all ${sorted.length.toLocaleString()}`}</Button>
98
+ </div>
99
+ ) : null}
100
+ {limit ? <FirstNotice shown={rows.length} limit={limit} noun={noun} /> : null}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RealtimeTab: who is on the site right now. Sites and traffic switches apply; the window does not.
5
+ * Now active visitors and page views in the last 5 and 30 minutes, and how fresh the data is
6
+ * Active pages / Traffic sources / Locations what the last 5 minutes look like
7
+ * The "last 5 minutes" end at the newest stored event, not at the clock: events reach the warehouse in batches, so a quiet
8
+ * clock window would always read 0. `data_as_of` says where the window ends; a lag over a minute is shown. The tab re-reads
9
+ * every `refreshSeconds` (default 10, the endpoint's cache TTL; 0 turns it off) while the page is visible, without dimming
10
+ * what is on screen. Locations need GeoIP enrichment upstream, so an empty list says that instead of "no visitors".
11
+ *
12
+ * SDK operations, in order (all read, free). RankedTable-style first-N notices: a full read says "Showing the first N".
13
+ * 1. getRealtime GET /analytics/realtime
14
+ * 2. getRealtimePages GET /analytics/realtime/pages (limit 20, maximum 50)
15
+ * 3. getRealtimeSources GET /analytics/realtime/sources (limit 20, maximum 50)
16
+ * 4. getRealtimeLocations GET /analytics/realtime/locations (limit 50)
17
+ */
18
+ import { useEffect } from "react";
19
+ import { BarList } from "@/g8/components/screen/bar-list";
20
+ import { FirstNotice } from "@/g8/components/paging-notices";
21
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
22
+ import { g8Get } from "@/g8/lib/g8-client";
23
+ import { useReport } from "../hooks/use-report";
24
+ import { trafficQuery } from "../lib/analytics-scope";
25
+ import type { ScopeState } from "../lib/analytics-scope";
26
+ import { fmtDateTime, fmtInt, num, readRow, readRows, str } from "../lib/web-analytics-types";
27
+ import type { RealtimeLocation, RealtimeOverview, RealtimePage, RealtimeSource } from "../lib/web-analytics-types";
28
+ import { ReportCard } from "./report-card";
29
+
30
+ const ROWS = 20;
31
+ const LOCATION_ROWS = 50;
32
+
33
+ export interface RealtimeTabProps {
34
+ scope: ScopeState;
35
+ refreshSeconds?: number;
36
+ }
37
+
38
+ function lagNote(d: RealtimeOverview): string {
39
+ const asOf = str(d.data_as_of);
40
+ if (!asOf) return "No page view was stored in the last 30 minutes.";
41
+ const lag = num(d.data_lag_seconds);
42
+ const behind = lag != null && lag >= 60 ? ` (${Math.round(lag / 60)} min behind the clock)` : "";
43
+ return `Live numbers end at the newest stored page view, ${fmtDateTime(asOf)}${behind}.`;
44
+ }
45
+
46
+ export function RealtimeTab({ scope, refreshSeconds = 10 }: RealtimeTabProps) {
47
+ const q = trafficQuery(scope);
48
+ const now = useReport(async (signal) => readRow<RealtimeOverview>(await g8Get("/analytics/realtime", q, { signal })), scope.key);
49
+ const pages = useReport(async (signal) => readRows<RealtimePage>(await g8Get("/analytics/realtime/pages", { ...q, limit: ROWS }, { signal })), scope.key);
50
+ const sources = useReport(async (signal) => readRows<RealtimeSource>(await g8Get("/analytics/realtime/sources", { ...q, limit: ROWS }, { signal })), scope.key);
51
+ const places = useReport(async (signal) => readRows<RealtimeLocation>(await g8Get("/analytics/realtime/locations", { ...q, limit: LOCATION_ROWS }, { signal })), scope.key);
52
+ const refetchers = [now.refetch, pages.refetch, sources.refetch, places.refetch];
53
+ useEffect(() => {
54
+ if (!refreshSeconds) return;
55
+ const timer = setInterval(() => {
56
+ if (typeof document === "undefined" || document.visibilityState !== "hidden") refetchers.forEach((r) => r());
57
+ }, refreshSeconds * 1000);
58
+ return () => clearInterval(timer);
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [refreshSeconds, scope.key]);
61
+ const none = (rows: unknown[]) => rows.length === 0;
62
+ return (
63
+ <div className="grid gap-4 lg:grid-cols-3">
64
+ <ReportCard className="lg:col-span-3" title="Now" description={refreshSeconds ? `Refreshes every ${refreshSeconds} seconds` : undefined} report={now} isEmpty={(d) => d === null}>
65
+ {(d) => (
66
+ <div className="space-y-2">
67
+ <StatStrip items={[
68
+ { label: "Active, last 5 minutes", value: fmtInt(d!.active_5m) },
69
+ { label: "Active, last 30 minutes", value: fmtInt(d!.active_30m) },
70
+ { label: "Page views, 5 minutes", value: fmtInt(d!.pageviews_5m) },
71
+ { label: "Page views, 30 minutes", value: fmtInt(d!.pageviews_30m) },
72
+ ]} />
73
+ <p className="text-xs text-text-color-muted" data-testid="realtime-freshness">{lagNote(d!)}</p>
74
+ </div>
75
+ )}
76
+ </ReportCard>
77
+ <ReportCard title="Active pages" report={pages} isEmpty={none} emptyTitle="No page views right now">
78
+ {(rows) => (
79
+ <div className="space-y-2">
80
+ <BarList items={rows.map((r) => ({ label: str(r.page_path) || str(r.page_title) || "Unknown page", value: num(r.visitors) ?? 0 }))} />
81
+ <FirstNotice shown={rows.length} limit={ROWS} noun="pages" />
82
+ </div>
83
+ )}
84
+ </ReportCard>
85
+ <ReportCard title="Traffic sources" report={sources} isEmpty={none} emptyTitle="No visitors right now">
86
+ {(rows) => (
87
+ <div className="space-y-2">
88
+ <BarList items={rows.map((r) => ({ label: str(r.source) || "Unknown", value: num(r.visitors) ?? 0 }))} />
89
+ <FirstNotice shown={rows.length} limit={ROWS} noun="sources" />
90
+ </div>
91
+ )}
92
+ </ReportCard>
93
+ <ReportCard title="Locations" report={places} isEmpty={none} emptyTitle="No locations right now" emptyDescription="Locations need GeoIP enrichment on the tracking pipeline, so this can be empty while visitors are on the site.">
94
+ {(rows) => (
95
+ <div className="space-y-2">
96
+ <BarList items={rows.map((r) => ({ label: [str(r.region), str(r.country)].filter(Boolean).join(", ") || "Unknown", value: num(r.visitors) ?? 0 }))} />
97
+ <FirstNotice shown={rows.length} limit={LOCATION_ROWS} noun="locations" />
98
+ </div>
99
+ )}
100
+ </ReportCard>
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ReportCard: one report in a titled card, with the five states every analytics card must get right.
5
+ *
6
+ * loading skeleton (first read, or Retry after a failure)
7
+ * error the error with Retry; the previous numbers are NOT shown under it
8
+ * unavailable the backend flagged the answer stale and there is nothing to show: "could not be computed", with Retry.
9
+ * It is never shown as an empty report, and any summary tiles that belong to this report are hidden with it
10
+ * empty a real answer with no rows: "No data in this window" (the empty text can be overridden)
11
+ * ready the content; while a new scope loads it is dimmed and marked busy, never silently left as the old answer
12
+ *
13
+ * SDK operations: none.
14
+ */
15
+ import type { ReactNode } from "react";
16
+ import { TriangleAlert } from "lucide-react";
17
+ import { Button, Skeleton, cn } from "@graph8/sdk/ui";
18
+ import { ApiError } from "@/g8/components/api-error";
19
+ import { EmptyState } from "@/g8/components/empty-state";
20
+ import { SectionCard } from "@/g8/components/screen/section-card";
21
+ import type { Report } from "../hooks/use-report";
22
+
23
+ export interface ReportCardProps<T> {
24
+ title: ReactNode;
25
+ description?: ReactNode;
26
+ report: Report<T>;
27
+ /** True when `data` holds nothing to draw (no rows, no row). */
28
+ isEmpty: (data: T) => boolean;
29
+ emptyTitle?: string;
30
+ emptyDescription?: ReactNode;
31
+ actions?: ReactNode;
32
+ /** Height of the loading skeleton. */
33
+ skeletonHeight?: number;
34
+ className?: string;
35
+ children: (data: T) => ReactNode;
36
+ }
37
+
38
+ export function ReportCard<T>({ title, description, report, isEmpty, emptyTitle = "No data in this window", emptyDescription, actions, skeletonHeight = 120, className, children }: ReportCardProps<T>) {
39
+ const { data, stale, loading, pending, error, refetch } = report;
40
+ let body: ReactNode;
41
+ if (error) {
42
+ body = <ApiError error={error} compact title="Could not load this report" onRetry={refetch} />;
43
+ } else if (loading || data === undefined) {
44
+ body = <Skeleton className="w-full" style={{ height: skeletonHeight }} aria-label="Loading report" aria-busy="true" />;
45
+ } else if (stale && isEmpty(data)) {
46
+ body = (
47
+ <div role="alert" className="flex items-start gap-2 rounded-md border border-border bg-muted px-3 py-3 text-sm">
48
+ <TriangleAlert className="mt-0.5 size-4 shrink-0 text-warning-foreground" strokeWidth={1.5} aria-hidden />
49
+ <div className="min-w-0 flex-1">
50
+ <p className="font-medium text-foreground">This report is unavailable right now</p>
51
+ <p className="text-text-color-muted">The query did not finish, so there is nothing to show. This is not zero traffic.</p>
52
+ </div>
53
+ <Button type="button" size="sm" variant="outline" onClick={refetch}>Retry</Button>
54
+ </div>
55
+ );
56
+ } else if (isEmpty(data)) {
57
+ body = <EmptyState compact title={emptyTitle} description={emptyDescription} />;
58
+ } else {
59
+ body = (
60
+ <div className={cn("space-y-3 transition-opacity", pending && "opacity-50")} aria-busy={pending || undefined} data-pending={pending ? "true" : undefined}>
61
+ {stale ? <p role="note" className="text-xs text-warning-foreground">The latest query failed. These are the last saved numbers and may be out of date.</p> : null}
62
+ {children(data)}
63
+ </div>
64
+ );
65
+ }
66
+ return (
67
+ <SectionCard title={title} description={description} actions={actions} className={className}>
68
+ {body}
69
+ </SectionCard>
70
+ );
71
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WebAnalyticsScreen: the finished website analytics product page. Seven tabs share ONE site picker and ONE window
5
+ * (kept in the URL: ?site=, ?days= or ?from=&to=, ?bots=1, ?internal=1, ?tab=), so a link opens the same view anywhere.
6
+ * Overview audience, traffic over time, conversion, top pages
7
+ * Acquisition channels, sources, campaigns, referrals, social and search
8
+ * Audience countries, cities, languages, technology, new or returning
9
+ * Pages top, landing and exit pages, events, and a detail panel for any page (?page=/path)
10
+ * Engagement sessions, depth, time on site, bounce, by channel, campaign and landing page
11
+ * Conversions funnel, goals, forms and form analysis
12
+ * Realtime who is on the site now
13
+ *
14
+ * Every tab is read only: no confirmations, nothing billable. Any filter, site or window change shows a loading state on
15
+ * each card (never the old numbers as if they were the new answer). Glue to other blocks is injected by the app:
16
+ * `resolveLandingPage(path)` returns the id of the landing page at exactly that address, and `onOpenLandingPage(id)` opens it.
17
+ *
18
+ * SDK operations: none of its own. The tabs and AnalyticsFilters make the reads (see each file).
19
+ * Prerequisite: the tracking snippet pasted into the website (a step only a person can do, see the visitors block).
20
+ */
21
+ import { useEffect, type ReactNode } from "react";
22
+ import { ChartNoAxesCombined } from "lucide-react";
23
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
24
+ import { useUrlState, writeUrlParam } from "@/g8/hooks/use-url-state";
25
+ import { useAnalyticsScope } from "../lib/analytics-scope";
26
+ import { AcquisitionTab } from "./acquisition-tab";
27
+ import { AnalyticsFilters } from "./analytics-filters";
28
+ import { AudienceTab } from "./audience-tab";
29
+ import { ConversionsTab } from "./conversions-tab";
30
+ import { EngagementTab } from "./engagement-tab";
31
+ import { OverviewTab } from "./overview-tab";
32
+ import { PagesTab } from "./pages-tab";
33
+ import { RealtimeTab } from "./realtime-tab";
34
+
35
+ export interface WebAnalyticsScreenProps {
36
+ /** The id of the landing page whose address is exactly this page path (one match only), or undefined. */
37
+ resolveLandingPage?: (path: string) => string | undefined;
38
+ /** Open that landing page (a route in the app, for example /pages?id=...). */
39
+ onOpenLandingPage?: (id: string) => void;
40
+ /** Re-read the realtime tab every N seconds (default 10; 0 turns it off). */
41
+ refreshSeconds?: number;
42
+ toolbarActions?: ReactNode;
43
+ className?: string;
44
+ }
45
+
46
+ const TABS = [
47
+ { id: "overview", label: "Overview" },
48
+ { id: "acquisition", label: "Acquisition" },
49
+ { id: "audience", label: "Audience" },
50
+ { id: "pages", label: "Pages" },
51
+ { id: "engagement", label: "Engagement" },
52
+ { id: "conversions", label: "Conversions" },
53
+ { id: "realtime", label: "Realtime" },
54
+ ] as const;
55
+ type TabId = (typeof TABS)[number]["id"];
56
+
57
+ export function WebAnalyticsScreen({ resolveLandingPage, onOpenLandingPage, refreshSeconds, toolbarActions, className }: WebAnalyticsScreenProps) {
58
+ const [tabRaw, setTab] = useUrlState("tab", "overview");
59
+ const tab: TabId = TABS.some((t) => t.id === tabRaw) ? (tabRaw as TabId) : "overview";
60
+ const scope = useAnalyticsScope();
61
+ // The page detail belongs to the Pages tab: leaving it closes the panel so the next visit starts on the list.
62
+ useEffect(() => {
63
+ if (tab !== "pages") writeUrlParam("page", null);
64
+ }, [tab]);
65
+ const openPage = (path: string) => {
66
+ writeUrlParam("page", path);
67
+ setTab("pages");
68
+ };
69
+ return (
70
+ <ScreenLayout
71
+ className={className}
72
+ icon={<ChartNoAxesCombined />}
73
+ title="Web analytics"
74
+ description="Traffic, audience, engagement and conversions for your sites"
75
+ actions={toolbarActions}
76
+ tabs={TABS.map((t) => ({ id: t.id, label: t.label }))}
77
+ activeTab={tab}
78
+ onTabChange={(id) => setTab(id === "overview" ? null : id)}
79
+ toolbar={<AnalyticsFilters scope={scope} showWindow={tab !== "realtime"} />}
80
+ >
81
+ {tab === "overview" ? <OverviewTab scope={scope} onOpenPage={openPage} onGoTab={(id) => setTab(id)} /> : null}
82
+ {tab === "acquisition" ? <AcquisitionTab scope={scope} /> : null}
83
+ {tab === "audience" ? <AudienceTab scope={scope} /> : null}
84
+ {tab === "pages" ? <PagesTab scope={scope} resolveLandingPage={resolveLandingPage} onOpenLandingPage={onOpenLandingPage} /> : null}
85
+ {tab === "engagement" ? <EngagementTab scope={scope} /> : null}
86
+ {tab === "conversions" ? <ConversionsTab scope={scope} /> : null}
87
+ {tab === "realtime" ? <RealtimeTab scope={scope} refreshSeconds={refreshSeconds} /> : null}
88
+ </ScreenLayout>
89
+ );
90
+ }
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useReport: one report read with the states every card needs, so no card invents its own.
5
+ *
6
+ * - `pending` is true while a request for the CURRENT scope is in flight and the data on screen belongs to an older
7
+ * one (a site, window or switch just changed): the card dims or shows a skeleton. A background poll of the same
8
+ * scope (realtime) does not set it, so a ticking card does not flicker.
9
+ * - `error` clears `data`: the last query's numbers never stay on screen under a failure or a refusal.
10
+ * - `stale` is the backend's `meta.stale` (the query failed and the service swallowed it).
11
+ *
12
+ * The caller passes the fetcher, so the component that calls g8Get declares the operation.
13
+ *
14
+ * SDK operations: none.
15
+ */
16
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
17
+
18
+ export interface Report<T> {
19
+ data: T | undefined;
20
+ stale: boolean;
21
+ /** First load: nothing to show yet. */
22
+ loading: boolean;
23
+ /** A new scope is loading while older data is still held. */
24
+ pending: boolean;
25
+ error: unknown;
26
+ refetch: () => void;
27
+ }
28
+
29
+ export function useReport<T>(
30
+ fetcher: (signal: AbortSignal) => Promise<{ data: T; stale: boolean }>,
31
+ key: string,
32
+ options: { enabled?: boolean } = {},
33
+ ): Report<T> {
34
+ // Each answer remembers the scope it was read for, so "this is an older scope's data" is known even in the render
35
+ // before the new request starts.
36
+ const q = useG8Query(async (signal) => ({ ...(await fetcher(signal)), key }), [key], { enabled: options.enabled });
37
+ const fresh = q.data?.key === key;
38
+ const failed = q.error !== undefined && !q.loading;
39
+ const hasData = q.data !== undefined && q.error === undefined;
40
+ return {
41
+ data: hasData ? q.data!.data : undefined,
42
+ stale: hasData ? q.data!.stale : false,
43
+ loading: !failed && !hasData,
44
+ pending: hasData && !fresh,
45
+ error: failed ? q.error : undefined,
46
+ refetch: () => void q.refetch(),
47
+ };
48
+ }