@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,135 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AudienceTab: who the visitors are.
5
+ * At a glance the top 10 countries, cities and languages in one read
6
+ * Locations every country or city (switch), as a re-sortable table
7
+ * Languages the browser languages
8
+ * Technology browsers, operating systems and devices
9
+ * New or returning visitors, and how many page views each group made
10
+ *
11
+ * SDK operations, in order (all read, free). RankedTable renders a FirstNotice ("Showing the first N") when a read came back full:
12
+ * 1. getAudienceDemographics GET /analytics/audience/demographics
13
+ * 2. getGeoCountries GET /analytics/audience/geo (limit 200)
14
+ * 3. getGeoCities GET /analytics/audience/geo/cities (limit 200)
15
+ * 4. getAudienceLanguages GET /analytics/audience/languages (limit 100, the API maximum)
16
+ * 5. getTechnologyOverview GET /analytics/audience/technology
17
+ * 6. getAudienceBehavior GET /analytics/audience/behavior
18
+ * Location rows without a country are not guessed: they read "Unknown".
19
+ */
20
+ import { useState } from "react";
21
+ import { Button } from "@graph8/sdk/ui";
22
+ import { BarList } from "@/g8/components/screen/bar-list";
23
+ import { g8Get } from "@/g8/lib/g8-client";
24
+ import { useReport } from "../hooks/use-report";
25
+ import { LIMITS, windowedQuery } from "../lib/analytics-scope";
26
+ import type { ScopeState } from "../lib/analytics-scope";
27
+ import { fmtInt, fmtPct, num, pctOf, readGroups, readRows, str, sumOf } from "../lib/web-analytics-types";
28
+ import type { GeoCity, GeoCountry, Language, NewVsReturning, Row } from "../lib/web-analytics-types";
29
+ import { RankedTable } from "./ranked-table";
30
+ import { ReportCard } from "./report-card";
31
+
32
+ const bars = (rows: Row[], name: string, value = "users") => <BarList items={rows.map((r) => ({ label: str(r[name]) || "Unknown", value: num(r[value]) ?? 0 }))} />;
33
+ const GROUP_KEYS = ["countries", "cities", "languages"] as const;
34
+ const TECH_KEYS = ["browsers", "operating_systems", "devices"] as const;
35
+
36
+ function GroupColumn({ title, children }: { title: string; children: React.ReactNode }) {
37
+ return (
38
+ <div className="min-w-0 space-y-2">
39
+ <h4 className="text-xs font-medium uppercase tracking-wide text-text-color-muted">{title}</h4>
40
+ {children}
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function Countries({ scope }: { scope: ScopeState }) {
46
+ const q = windowedQuery(scope);
47
+ const report = useReport(async (signal) => readRows<GeoCountry>(await g8Get("/analytics/audience/geo", { ...q, limit: LIMITS.geo }, { signal })), scope.key);
48
+ return (
49
+ <ReportCard title="Countries" report={report} isEmpty={(r) => r.length === 0} emptyDescription="Locations need GeoIP enrichment on the tracking pipeline.">
50
+ {(rows) => (
51
+ <RankedTable rows={rows} noun="countries" limit={LIMITS.geo} defaultSort="users" rowKey={(r, i) => `${str(r.country_code)}|${i}`} columns={[
52
+ { key: "country", label: "Country", render: (r) => str(r.country) || "Unknown", sortBy: (r) => str(r.country) },
53
+ { key: "users", label: "Visitors", numeric: true, render: (r) => fmtInt(r.users), sortBy: (r) => num(r.users) },
54
+ { key: "sessions", label: "Sessions", numeric: true, render: (r) => fmtInt(r.sessions), sortBy: (r) => num(r.sessions) },
55
+ ]} />
56
+ )}
57
+ </ReportCard>
58
+ );
59
+ }
60
+
61
+ function Cities({ scope }: { scope: ScopeState }) {
62
+ const q = windowedQuery(scope);
63
+ const report = useReport(async (signal) => readRows<GeoCity>(await g8Get("/analytics/audience/geo/cities", { ...q, limit: LIMITS.cities }, { signal })), scope.key);
64
+ return (
65
+ <ReportCard title="Cities" report={report} isEmpty={(r) => r.length === 0} emptyDescription="Locations need GeoIP enrichment on the tracking pipeline.">
66
+ {(rows) => (
67
+ <RankedTable rows={rows} noun="cities" limit={LIMITS.cities} defaultSort="users" rowKey={(r, i) => `${str(r.city)}|${str(r.country_code)}|${i}`} columns={[
68
+ { key: "city", label: "City", render: (r) => str(r.city) || "Unknown", sortBy: (r) => str(r.city) },
69
+ { key: "region", label: "Region", render: (r) => str(r.region), sortBy: (r) => str(r.region) },
70
+ { key: "country", label: "Country", render: (r) => str(r.country), sortBy: (r) => str(r.country) },
71
+ { key: "users", label: "Visitors", numeric: true, render: (r) => fmtInt(r.users), sortBy: (r) => num(r.users) },
72
+ { key: "sessions", label: "Sessions", numeric: true, render: (r) => fmtInt(r.sessions), sortBy: (r) => num(r.sessions) },
73
+ ]} />
74
+ )}
75
+ </ReportCard>
76
+ );
77
+ }
78
+
79
+ export function AudienceTab({ scope }: { scope: ScopeState }) {
80
+ const q = windowedQuery(scope);
81
+ const [place, setPlace] = useState<"countries" | "cities">("countries");
82
+ const demographics = useReport(async (signal) => readGroups(await g8Get("/analytics/audience/demographics", q, { signal }), GROUP_KEYS), scope.key);
83
+ const languages = useReport(async (signal) => readRows<Language>(await g8Get("/analytics/audience/languages", { ...q, limit: LIMITS.languages }, { signal })), scope.key);
84
+ const tech = useReport(async (signal) => readGroups(await g8Get("/analytics/audience/technology", q, { signal }), TECH_KEYS), scope.key);
85
+ const behavior = useReport(async (signal) => readRows<NewVsReturning>(await g8Get("/analytics/audience/behavior", q, { signal })), scope.key);
86
+ return (
87
+ <div className="grid gap-4 lg:grid-cols-2">
88
+ <ReportCard className="lg:col-span-2" title="At a glance" description="Top 10 of each" report={demographics} isEmpty={(d) => d === null || GROUP_KEYS.every((k) => d[k].length === 0)}>
89
+ {(d) => (
90
+ <div className="grid gap-6 md:grid-cols-3">
91
+ <GroupColumn title="Countries">{bars(d!["countries"], "country")}</GroupColumn>
92
+ <GroupColumn title="Cities">{bars(d!["cities"], "city")}</GroupColumn>
93
+ <GroupColumn title="Languages">{bars(d!["languages"], "language")}</GroupColumn>
94
+ </div>
95
+ )}
96
+ </ReportCard>
97
+ <div className="space-y-2 lg:col-span-2">
98
+ <div role="group" aria-label="Locations by" className="inline-flex gap-1">
99
+ <Button type="button" size="sm" variant={place === "countries" ? "default" : "outline"} aria-pressed={place === "countries"} onClick={() => setPlace("countries")}>Countries</Button>
100
+ <Button type="button" size="sm" variant={place === "cities" ? "default" : "outline"} aria-pressed={place === "cities"} onClick={() => setPlace("cities")}>Cities</Button>
101
+ </div>
102
+ {place === "countries" ? <Countries scope={scope} /> : <Cities scope={scope} />}
103
+ </div>
104
+ <ReportCard title="Languages" report={languages} isEmpty={(r) => r.length === 0}>
105
+ {(rows) => (
106
+ <RankedTable rows={rows} noun="languages" limit={LIMITS.languages} defaultSort="users" rowKey={(r, i) => `${str(r.language)}|${i}`} columns={[
107
+ { key: "language", label: "Language", render: (r) => str(r.language) || "Unknown", sortBy: (r) => str(r.language) },
108
+ { key: "users", label: "Visitors", numeric: true, render: (r) => fmtInt(r.users), sortBy: (r) => num(r.users) },
109
+ { key: "sessions", label: "Sessions", numeric: true, render: (r) => fmtInt(r.sessions), sortBy: (r) => num(r.sessions) },
110
+ ]} />
111
+ )}
112
+ </ReportCard>
113
+ <ReportCard title="New or returning" report={behavior} isEmpty={(r) => r.length === 0}>
114
+ {(rows) => {
115
+ const total = sumOf(rows, (r) => r.users);
116
+ return (
117
+ <div className="space-y-2">
118
+ <BarList items={rows.map((r) => ({ label: `${str(r.user_type) || "Unknown"} visitors`, value: num(r.users) ?? 0, display: `${fmtInt(r.users)} (${fmtPct(pctOf(r.users, total), 0)})` }))} />
119
+ <p className="text-xs text-text-color-muted">{rows.map((r) => `${str(r.user_type) || "Unknown"}: ${fmtInt(r.page_views)} page views`).join(". ")}.</p>
120
+ </div>
121
+ );
122
+ }}
123
+ </ReportCard>
124
+ <ReportCard className="lg:col-span-2" title="Technology" report={tech} isEmpty={(d) => d === null || TECH_KEYS.every((k) => d[k].length === 0)}>
125
+ {(d) => (
126
+ <div className="grid gap-6 md:grid-cols-3">
127
+ <GroupColumn title="Browsers">{bars(d!["browsers"], "browser")}</GroupColumn>
128
+ <GroupColumn title="Operating systems">{bars(d!["operating_systems"], "os")}</GroupColumn>
129
+ <GroupColumn title="Devices">{bars(d!["devices"], "device_category")}</GroupColumn>
130
+ </div>
131
+ )}
132
+ </ReportCard>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ConversionsTab: what visitors do that matters.
5
+ * Summary visitors, converters and the conversion rate (a dash when there were no visitors)
6
+ * Funnel site visitors, viewed a form, submitted a form, booked a meeting, with the step-to-step rate
7
+ * Goals completions and conversion rate per goal, and a daily trend
8
+ * Forms submissions per form, a daily trend, and form analysis (viewers, submitters, conversion rate)
9
+ * Rates the API sends as null (no visitors or no viewers) stay a dash; a rate is never filled in with 0%.
10
+ *
11
+ * SDK operations, in order (all read, free). RankedTable renders a FirstNotice ("Showing the first N") when a read came back full:
12
+ * 1. getConversionRate GET /analytics/conversions/rate
13
+ * 2. getFunnel GET /analytics/conversions/funnel
14
+ * 3. getGoals GET /analytics/conversions/goals
15
+ * 4. getGoalsTrend GET /analytics/conversions/goals/trend
16
+ * 5. getForms GET /analytics/conversions/forms (limit 200)
17
+ * 6. getFormsTrend GET /analytics/conversions/forms/trend
18
+ * 7. getFormAnalysis GET /analytics/conversions/forms/analysis (limit 100, the API maximum)
19
+ */
20
+ import { useState } from "react";
21
+ import { Button } from "@graph8/sdk/ui";
22
+ import { BarList } from "@/g8/components/screen/bar-list";
23
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
24
+ import { TimeSeriesChart } from "@/g8/components/screen/time-series-chart";
25
+ import { g8Get } from "@/g8/lib/g8-client";
26
+ import { useReport } from "../hooks/use-report";
27
+ import { LIMITS, windowedQuery } from "../lib/analytics-scope";
28
+ import type { ScopeState } from "../lib/analytics-scope";
29
+ import { fmtDateTime, fmtDay, fmtInt, fmtPct, num, pctOf, readRow, readRows, str } from "../lib/web-analytics-types";
30
+ import type { ConversionRate, FormAnalysisRow, FormRow, FormTrendPoint, FunnelStep, GoalRow, GoalTrendPoint } from "../lib/web-analytics-types";
31
+ import { RankedTable } from "./ranked-table";
32
+ import { ReportCard } from "./report-card";
33
+
34
+ const GOAL_SERIES = [
35
+ { key: "total_conversions", label: "All conversions" },
36
+ { key: "form_submissions", label: "Form submissions" },
37
+ { key: "meetings_booked", label: "Meetings booked" },
38
+ { key: "unique_converters", label: "People who converted" },
39
+ ] as const;
40
+
41
+ function Funnel({ steps }: { steps: FunnelStep[] }) {
42
+ const ordered = [...steps].sort((a, b) => (num(a.step_order) ?? 0) - (num(b.step_order) ?? 0));
43
+ return (
44
+ <div className="space-y-3">
45
+ <BarList
46
+ emphasizeFirst={false}
47
+ items={ordered.map((s, i) => {
48
+ const prev = i > 0 ? num(ordered[i - 1].users) : undefined;
49
+ const step = i > 0 ? pctOf(s.users, prev) : undefined;
50
+ return { label: str(s.step), value: num(s.users) ?? 0, display: `${fmtInt(s.users)} (${fmtPct(s.percentage, 1)} of visitors${i > 0 ? `, ${fmtPct(step, 1)} of previous step` : ""})` };
51
+ })}
52
+ />
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export function ConversionsTab({ scope }: { scope: ScopeState }) {
58
+ const q = windowedQuery(scope);
59
+ const [series, setSeries] = useState<(typeof GOAL_SERIES)[number]["key"]>("total_conversions");
60
+ const rate = useReport(async (signal) => readRow<ConversionRate>(await g8Get("/analytics/conversions/rate", q, { signal })), scope.key);
61
+ const funnel = useReport(async (signal) => readRows<FunnelStep>(await g8Get("/analytics/conversions/funnel", q, { signal })), scope.key);
62
+ const goals = useReport(async (signal) => readRows<GoalRow>(await g8Get("/analytics/conversions/goals", q, { signal })), scope.key);
63
+ const goalsTrend = useReport(async (signal) => readRows<GoalTrendPoint>(await g8Get("/analytics/conversions/goals/trend", q, { signal })), scope.key);
64
+ const forms = useReport(async (signal) => readRows<FormRow>(await g8Get("/analytics/conversions/forms", { ...q, limit: LIMITS.forms }, { signal })), scope.key);
65
+ const formsTrend = useReport(async (signal) => readRows<FormTrendPoint>(await g8Get("/analytics/conversions/forms/trend", q, { signal })), scope.key);
66
+ const analysis = useReport(async (signal) => readRows<FormAnalysisRow>(await g8Get("/analytics/conversions/forms/analysis", { ...q, limit: LIMITS.formAnalysis }, { signal })), scope.key);
67
+ const none = (rows: unknown[]) => rows.length === 0;
68
+ const label = GOAL_SERIES.find((s) => s.key === series)!.label;
69
+ return (
70
+ <div className="grid gap-4 lg:grid-cols-2">
71
+ <ReportCard className="lg:col-span-2" title="Summary" report={rate} isEmpty={(d) => d === null}>
72
+ {(d) => (
73
+ <StatStrip items={[
74
+ { label: "Visitors", value: fmtInt(d!.total_visitors) },
75
+ { label: "Converted", value: fmtInt(d!.total_converters) },
76
+ { label: "Conversion rate", value: fmtPct(d!.conversion_rate, 2), hint: "A dash means there were no visitors in this window." },
77
+ ]} />
78
+ )}
79
+ </ReportCard>
80
+ <ReportCard title="Funnel" description="Site visitors to booked meeting" report={funnel} isEmpty={none}>
81
+ {(steps) => <Funnel steps={steps} />}
82
+ </ReportCard>
83
+ <ReportCard title="Goals" report={goals} isEmpty={none} emptyTitle="No goal completions in this window">
84
+ {(rows) => (
85
+ <RankedTable rows={rows} noun="goals" defaultSort="completions" rowKey={(r, i) => `${str(r.goal_name)}|${i}`} columns={[
86
+ { key: "goal", label: "Goal", render: (r) => str(r.goal_name) || "Unknown", sortBy: (r) => str(r.goal_name) },
87
+ { key: "completions", label: "Completions", numeric: true, render: (r) => fmtInt(r.completions), sortBy: (r) => num(r.completions) },
88
+ { key: "people", label: "People", numeric: true, render: (r) => fmtInt(r.unique_completions), sortBy: (r) => num(r.unique_completions) },
89
+ { key: "rate", label: "Conversion rate", numeric: true, render: (r) => fmtPct(r.conversion_rate, 2), sortBy: (r) => num(r.conversion_rate) },
90
+ ]} />
91
+ )}
92
+ </ReportCard>
93
+ <ReportCard
94
+ className="lg:col-span-2"
95
+ title="Goals over time"
96
+ report={goalsTrend}
97
+ isEmpty={none}
98
+ skeletonHeight={220}
99
+ actions={<div role="group" aria-label="Goal metric" className="flex flex-wrap gap-1">{GOAL_SERIES.map((s) => <Button key={s.key} type="button" size="sm" variant={series === s.key ? "default" : "outline"} aria-pressed={series === s.key} onClick={() => setSeries(s.key)}>{s.label}</Button>)}</div>}
100
+ >
101
+ {(rows) => <TimeSeriesChart data={rows.map((r) => ({ ...r }))} xKey="date" series={[{ key: series, label }]} kind="bar" height={220} formatX={fmtDay} formatY={(v) => v.toLocaleString()} />}
102
+ </ReportCard>
103
+ <ReportCard className="lg:col-span-2" title="Forms" description="Submissions per form" report={forms} isEmpty={none} emptyTitle="No form submissions in this window">
104
+ {(rows) => (
105
+ <RankedTable rows={rows} noun="forms" limit={LIMITS.forms} defaultSort="submissions" rowKey={(r, i) => `${str(r.form_id)}|${i}`} columns={[
106
+ { key: "form", label: "Form", render: (r) => str(r.form_name) || str(r.form_id) || "Unknown", sortBy: (r) => str(r.form_name) },
107
+ { key: "submissions", label: "Submissions", numeric: true, render: (r) => fmtInt(r.submissions), sortBy: (r) => num(r.submissions) },
108
+ { key: "submitters", label: "People", numeric: true, render: (r) => fmtInt(r.unique_submitters), sortBy: (r) => num(r.unique_submitters) },
109
+ { key: "latest", label: "Latest", render: (r) => fmtDateTime(r.latest_submission), sortBy: (r) => str(r.latest_submission) || undefined },
110
+ ]} />
111
+ )}
112
+ </ReportCard>
113
+ <ReportCard title="Form submissions over time" report={formsTrend} isEmpty={none} skeletonHeight={200}>
114
+ {(rows) => <TimeSeriesChart data={rows.map((r) => ({ ...r }))} xKey="date" series={[{ key: "submissions", label: "Submissions" }]} kind="bar" height={200} formatX={fmtDay} formatY={(v) => v.toLocaleString()} />}
115
+ </ReportCard>
116
+ <ReportCard title="Form analysis" description="How many viewers submit" report={analysis} isEmpty={none}>
117
+ {(rows) => (
118
+ <RankedTable rows={rows} noun="forms" limit={LIMITS.formAnalysis} defaultSort="rate" rowKey={(r, i) => `${str(r.form_id)}|${i}`} columns={[
119
+ { key: "form", label: "Form", render: (r) => str(r.form_name) || str(r.form_id) || "Unknown", sortBy: (r) => str(r.form_name) },
120
+ { key: "viewers", label: "Viewers", numeric: true, render: (r) => fmtInt(r.viewers), sortBy: (r) => num(r.viewers) },
121
+ { key: "submitters", label: "Submitters", numeric: true, render: (r) => fmtInt(r.unique_submitters), sortBy: (r) => num(r.unique_submitters) },
122
+ { key: "rate", label: "Conversion rate", numeric: true, render: (r) => fmtPct(r.conversion_rate, 2), sortBy: (r) => num(r.conversion_rate) },
123
+ ]} />
124
+ )}
125
+ </ReportCard>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ /**
4
+ * EngagementTab: how deeply visitors use the site.
5
+ * Summary sessions, pages per session, time on site, bounce rate, engaged share, each against the previous period
6
+ * Trend one daily metric at a time
7
+ * Depth and time how sessions spread over pages viewed and over time on site
8
+ * By channel / campaign / landing page re-sortable tables
9
+ * A session is one visitor's activity within one hour; "engaged" means more than one page and more than 30 seconds.
10
+ * A change against the previous period is shown only when the API sent it (it is null when the previous period had no data).
11
+ *
12
+ * SDK operations, in order (all read, free). RankedTable renders a FirstNotice ("Showing the first N") when a read came back full:
13
+ * 1. getEngagementOverview GET /analytics/engagement/overview
14
+ * 2. getEngagementTrend GET /analytics/engagement/trend
15
+ * 3. getSessionDepthDistribution GET /analytics/engagement/depth-distribution
16
+ * 4. getTimeOnSiteDistribution GET /analytics/engagement/time-distribution
17
+ * 5. getEngagementByChannel GET /analytics/engagement/by-channel
18
+ * 6. getEngagementByCampaign GET /analytics/engagement/by-campaign (limit 200)
19
+ * 7. getEngagementByLandingPage GET /analytics/engagement/by-landing-page (limit 100, the API maximum)
20
+ */
21
+ import { useState } from "react";
22
+ import { Button } from "@graph8/sdk/ui";
23
+ import { BarList } from "@/g8/components/screen/bar-list";
24
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
25
+ import type { StatItem } from "@/g8/components/screen/stat-strip";
26
+ import { TimeSeriesChart } from "@/g8/components/screen/time-series-chart";
27
+ import { g8Get } from "@/g8/lib/g8-client";
28
+ import { useReport } from "../hooks/use-report";
29
+ import { LIMITS, windowedQuery } from "../lib/analytics-scope";
30
+ import type { ScopeState } from "../lib/analytics-scope";
31
+ import { fmtDay, fmtDecimal, fmtInt, fmtMinutes, fmtPct, num, readRow, readRows, str } from "../lib/web-analytics-types";
32
+ import type { DepthBucket, EngagementByCampaign, EngagementByChannel, EngagementByLanding, EngagementOverview, EngagementTrendPoint, TimeBucket } from "../lib/web-analytics-types";
33
+ import { RankedTable } from "./ranked-table";
34
+ import { ReportCard } from "./report-card";
35
+
36
+ const METRICS = [
37
+ { key: "sessions", label: "Sessions", fmt: (v: number) => v.toLocaleString() },
38
+ { key: "avg_session_depth", label: "Pages per session", fmt: (v: number) => fmtDecimal(v, 2) },
39
+ { key: "avg_time_minutes", label: "Time on site", fmt: (v: number) => fmtMinutes(v) },
40
+ { key: "bounce_rate", label: "Bounce rate", fmt: (v: number) => fmtPct(v) },
41
+ { key: "engaged_sessions", label: "Engaged sessions", fmt: (v: number) => v.toLocaleString() },
42
+ ] as const;
43
+
44
+ /** A change against the previous period as a tile delta; absent when the API had none. */
45
+ function delta(v: unknown): Pick<StatItem, "delta" | "tone"> {
46
+ const n = num(v);
47
+ if (n == null) return {};
48
+ return { delta: n, tone: n > 0 ? "positive" : n < 0 ? "negative" : undefined };
49
+ }
50
+
51
+ function summaryTiles(d: EngagementOverview): StatItem[] {
52
+ return [
53
+ { label: "Sessions", value: fmtInt(d.total_sessions) },
54
+ { label: "Pages per session", value: fmtDecimal(d.avg_session_depth, 2), ...delta(d.depth_trend_pct) },
55
+ { label: "Time on site", value: fmtMinutes(d.avg_time_minutes), ...delta(d.time_trend_pct) },
56
+ { label: "Bounce rate", value: fmtPct(d.bounce_rate), hint: "Sessions with a single page view. A positive change means the bounce rate fell.", ...delta(d.bounce_trend_pct) },
57
+ { label: "Engaged sessions", value: fmtPct(d.engaged_pct), hint: "More than one page and more than 30 seconds", ...delta(d.engaged_trend_pct) },
58
+ ];
59
+ }
60
+
61
+ const NUM_COLS = <R extends { sessions?: number; avg_session_depth?: number; avg_time_minutes?: number; bounce_rate?: number; engaged_pct?: number }>() => [
62
+ { key: "sessions", label: "Sessions", numeric: true, render: (r: R) => fmtInt(r.sessions), sortBy: (r: R) => num(r.sessions) },
63
+ { key: "depth", label: "Pages", numeric: true, render: (r: R) => fmtDecimal(r.avg_session_depth, 2), sortBy: (r: R) => num(r.avg_session_depth) },
64
+ { key: "time", label: "Time", numeric: true, render: (r: R) => fmtMinutes(r.avg_time_minutes), sortBy: (r: R) => num(r.avg_time_minutes) },
65
+ { key: "bounce", label: "Bounce rate", numeric: true, render: (r: R) => fmtPct(r.bounce_rate), sortBy: (r: R) => num(r.bounce_rate) },
66
+ { key: "engaged", label: "Engaged", numeric: true, render: (r: R) => fmtPct(r.engaged_pct), sortBy: (r: R) => num(r.engaged_pct) },
67
+ ];
68
+
69
+ export function EngagementTab({ scope }: { scope: ScopeState }) {
70
+ const q = windowedQuery(scope);
71
+ const [metric, setMetric] = useState<(typeof METRICS)[number]["key"]>("sessions");
72
+ const overview = useReport(async (signal) => readRow<EngagementOverview>(await g8Get("/analytics/engagement/overview", q, { signal })), scope.key);
73
+ const trend = useReport(async (signal) => readRows<EngagementTrendPoint>(await g8Get("/analytics/engagement/trend", q, { signal })), scope.key);
74
+ const depth = useReport(async (signal) => readRows<DepthBucket>(await g8Get("/analytics/engagement/depth-distribution", q, { signal })), scope.key);
75
+ const time = useReport(async (signal) => readRows<TimeBucket>(await g8Get("/analytics/engagement/time-distribution", q, { signal })), scope.key);
76
+ const byChannel = useReport(async (signal) => readRows<EngagementByChannel>(await g8Get("/analytics/engagement/by-channel", q, { signal })), scope.key);
77
+ const byCampaign = useReport(async (signal) => readRows<EngagementByCampaign>(await g8Get("/analytics/engagement/by-campaign", { ...q, limit: LIMITS.byCampaign }, { signal })), scope.key);
78
+ const byLanding = useReport(async (signal) => readRows<EngagementByLanding>(await g8Get("/analytics/engagement/by-landing-page", { ...q, limit: LIMITS.byLanding }, { signal })), scope.key);
79
+ const none = (rows: unknown[]) => rows.length === 0;
80
+ const m = METRICS.find((x) => x.key === metric)!;
81
+ return (
82
+ <div className="grid gap-4 lg:grid-cols-2">
83
+ <ReportCard className="lg:col-span-2" title="Summary" report={overview} isEmpty={(d) => d === null}>
84
+ {(d) => <StatStrip items={summaryTiles(d!)} />}
85
+ </ReportCard>
86
+ <ReportCard
87
+ className="lg:col-span-2"
88
+ title="Trend"
89
+ report={trend}
90
+ isEmpty={none}
91
+ skeletonHeight={220}
92
+ actions={
93
+ <div role="group" aria-label="Metric" className="flex flex-wrap gap-1">
94
+ {METRICS.map((x) => <Button key={x.key} type="button" size="sm" variant={metric === x.key ? "default" : "outline"} aria-pressed={metric === x.key} onClick={() => setMetric(x.key)}>{x.label}</Button>)}
95
+ </div>
96
+ }
97
+ >
98
+ {(rows) => <TimeSeriesChart data={rows.map((r) => ({ ...r }))} xKey="date" series={[{ key: metric, label: m.label }]} kind="line" height={220} formatX={fmtDay} formatY={m.fmt} />}
99
+ </ReportCard>
100
+ <ReportCard title="Pages per session" description="Share of sessions" report={depth} isEmpty={none}>
101
+ {(rows) => <BarList items={rows.map((r) => ({ label: str(r.depth_bucket), value: num(r.sessions) ?? 0, display: `${fmtInt(r.sessions)} (${fmtPct(r.percentage, 0)})` }))} emphasizeFirst={false} />}
102
+ </ReportCard>
103
+ <ReportCard title="Time on site" description="Share of sessions" report={time} isEmpty={none}>
104
+ {(rows) => <BarList items={rows.map((r) => ({ label: str(r.duration_bucket), value: num(r.sessions) ?? 0, display: `${fmtInt(r.sessions)} (${fmtPct(r.percentage, 0)})` }))} emphasizeFirst={false} />}
105
+ </ReportCard>
106
+ <ReportCard className="lg:col-span-2" title="By channel" report={byChannel} isEmpty={none}>
107
+ {(rows) => (
108
+ <RankedTable rows={rows} noun="channels" defaultSort="sessions" rowKey={(r, i) => `${str(r.channel)}|${i}`} columns={[{ key: "channel", label: "Channel", render: (r) => str(r.channel) || "Unknown", sortBy: (r) => str(r.channel) }, ...NUM_COLS<EngagementByChannel>()]} />
109
+ )}
110
+ </ReportCard>
111
+ <ReportCard className="lg:col-span-2" title="By campaign" report={byCampaign} isEmpty={none} emptyTitle="No tagged campaigns in this window">
112
+ {(rows) => (
113
+ <RankedTable rows={rows} noun="campaigns" limit={LIMITS.byCampaign} defaultSort="sessions" rowKey={(r, i) => `${str(r.campaign)}|${str(r.source)}|${i}`} columns={[{ key: "campaign", label: "Campaign", render: (r) => str(r.campaign) || "Unknown", sortBy: (r) => str(r.campaign) }, { key: "source", label: "Source", render: (r) => str(r.source), sortBy: (r) => str(r.source) }, ...NUM_COLS<EngagementByCampaign>()]} />
114
+ )}
115
+ </ReportCard>
116
+ <ReportCard className="lg:col-span-2" title="By landing page" report={byLanding} isEmpty={none}>
117
+ {(rows) => (
118
+ <RankedTable rows={rows} noun="landing pages" limit={LIMITS.byLanding} defaultSort="sessions" rowKey={(r, i) => `${str(r.landing_page)}|${i}`} columns={[{ key: "page", label: "Landing page", render: (r) => str(r.landing_page) || "Unknown", sortBy: (r) => str(r.landing_page) }, ...NUM_COLS<EngagementByLanding>()]} />
119
+ )}
120
+ </ReportCard>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ /**
4
+ * OverviewTab: the headline numbers of the site for the chosen sites and window.
5
+ * Audience visitors, new visitors, sessions, page views (tiles belong to this card and vanish with it if it is unavailable)
6
+ * Traffic one daily series at a time (visitors, sessions or page views)
7
+ * Conversion visitors that converted, as a rate (a dash when there were no visitors)
8
+ * Top pages the most viewed pages; a row opens the page detail on the Pages tab
9
+ *
10
+ * SDK operations, in order (all read, free):
11
+ * 1. getAudienceOverview GET /analytics/audience/overview
12
+ * 2. getAudienceTrend GET /analytics/audience/trend
13
+ * 3. getConversionRate GET /analytics/conversions/rate
14
+ * 4. getTopPages GET /analytics/behavior/pages (top 8 by views; the ranking, not a list to scroll)
15
+ * Types: see lib/web-analytics-types.ts (the contract leaves these payloads untyped).
16
+ */
17
+ import { useState } from "react";
18
+ import { Button } from "@graph8/sdk/ui";
19
+ import { BarList } from "@/g8/components/screen/bar-list";
20
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
21
+ import { TimeSeriesChart } from "@/g8/components/screen/time-series-chart";
22
+ import { g8Get } from "@/g8/lib/g8-client";
23
+ import { useReport } from "../hooks/use-report";
24
+ import { windowedQuery } from "../lib/analytics-scope";
25
+ import type { ScopeState } from "../lib/analytics-scope";
26
+ import { fmtDay, fmtInt, fmtPct, num, pctOf, readRow, readRows, str } from "../lib/web-analytics-types";
27
+ import type { AudienceOverview, ConversionRate, PageRow, TrendPoint } from "../lib/web-analytics-types";
28
+ import { ReportCard } from "./report-card";
29
+
30
+ export interface OverviewTabProps {
31
+ scope: ScopeState;
32
+ onOpenPage: (path: string) => void;
33
+ onGoTab: (tab: string) => void;
34
+ }
35
+
36
+ const METRICS = [
37
+ { key: "users", label: "Visitors" },
38
+ { key: "sessions", label: "Sessions" },
39
+ { key: "page_views", label: "Page views" },
40
+ ] as const;
41
+
42
+ export function OverviewTab({ scope, onOpenPage, onGoTab }: OverviewTabProps) {
43
+ const q = windowedQuery(scope);
44
+ const [metric, setMetric] = useState<(typeof METRICS)[number]["key"]>("users");
45
+ const overview = useReport(async (signal) => readRow<AudienceOverview>(await g8Get("/analytics/audience/overview", q, { signal })), scope.key);
46
+ const trend = useReport(async (signal) => readRows<TrendPoint>(await g8Get("/analytics/audience/trend", q, { signal })), scope.key);
47
+ const rate = useReport(async (signal) => readRow<ConversionRate>(await g8Get("/analytics/conversions/rate", q, { signal })), scope.key);
48
+ const pages = useReport(async (signal) => readRows<PageRow>(await g8Get("/analytics/behavior/pages", { ...q, limit: 8 }, { signal })), scope.key);
49
+ const series = METRICS.find((m) => m.key === metric)!;
50
+ return (
51
+ <div className="grid gap-4 lg:grid-cols-2">
52
+ <ReportCard className="lg:col-span-2" title="Audience" report={overview} isEmpty={(d) => d === null}>
53
+ {(d) => {
54
+ const newShare = pctOf(d!.new_users, d!.total_users);
55
+ return (
56
+ <StatStrip
57
+ items={[
58
+ { label: "Visitors", value: fmtInt(d!.total_users) },
59
+ { label: "New visitors", value: fmtInt(d!.new_users), hint: newShare == null ? undefined : `${fmtPct(newShare)} of visitors in this window were new` },
60
+ { label: "Sessions", value: fmtInt(d!.sessions), hint: "A visitor counts once per hour of activity" },
61
+ { label: "Page views", value: fmtInt(d!.page_views) },
62
+ ]}
63
+ />
64
+ );
65
+ }}
66
+ </ReportCard>
67
+ <ReportCard
68
+ className="lg:col-span-2"
69
+ title="Traffic"
70
+ report={trend}
71
+ isEmpty={(rows) => rows.length === 0}
72
+ emptyTitle="No traffic in this window"
73
+ emptyDescription="Visits appear once the tracking snippet is live on the site."
74
+ skeletonHeight={220}
75
+ actions={
76
+ <div role="group" aria-label="Metric" className="inline-flex gap-1">
77
+ {METRICS.map((m) => (
78
+ <Button key={m.key} type="button" size="sm" variant={metric === m.key ? "default" : "outline"} aria-pressed={metric === m.key} onClick={() => setMetric(m.key)}>{m.label}</Button>
79
+ ))}
80
+ </div>
81
+ }
82
+ >
83
+ {(rows) => <TimeSeriesChart data={rows.map((r) => ({ ...r }))} xKey="date" series={[{ key: metric, label: series.label }]} kind="area" height={220} formatX={fmtDay} formatY={(v) => v.toLocaleString()} />}
84
+ </ReportCard>
85
+ <ReportCard title="Conversion" description="Visitors who submitted a form or booked a meeting" report={rate} isEmpty={(d) => d === null}>
86
+ {(d) => (
87
+ <div className="space-y-2">
88
+ <StatStrip items={[{ label: "Conversion rate", value: fmtPct(d!.conversion_rate, 2), hint: "Converting visitors divided by all visitors. A dash means there were no visitors." }, { label: "Converted", value: fmtInt(d!.total_converters) }]} />
89
+ <Button type="button" size="sm" variant="outline" onClick={() => onGoTab("conversions")}>See goals and funnel</Button>
90
+ </div>
91
+ )}
92
+ </ReportCard>
93
+ <ReportCard title="Top pages" description="By views" report={pages} isEmpty={(rows) => rows.length === 0} emptyTitle="No page views in this window">
94
+ {(rows) => (
95
+ <BarList
96
+ items={rows.map((p) => ({ label: str(p.page_path) || str(p.page_title) || "Unknown page", value: num(p.views) ?? 0, onClick: p.page_path ? () => onOpenPage(str(p.page_path)) : undefined }))}
97
+ />
98
+ )}
99
+ </ReportCard>
100
+ </div>
101
+ );
102
+ }