lytx 0.3.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 (213) hide show
  1. package/.env.example +37 -0
  2. package/README.md +486 -0
  3. package/alchemy.run.ts +155 -0
  4. package/cli/bootstrap-admin.ts +284 -0
  5. package/cli/deploy-staging.ts +692 -0
  6. package/cli/import-events.ts +628 -0
  7. package/cli/import-sites.ts +518 -0
  8. package/cli/index.ts +609 -0
  9. package/cli/init-db.ts +269 -0
  10. package/cli/migrate-to-durable-objects.ts +564 -0
  11. package/cli/migration-worker.ts +300 -0
  12. package/cli/performance-test.ts +588 -0
  13. package/cli/pg/client.ts +4 -0
  14. package/cli/pg/new-site.ts +153 -0
  15. package/cli/rollback-durable-objects.ts +622 -0
  16. package/cli/seed-data.ts +459 -0
  17. package/cli/setup.js +18 -0
  18. package/cli/setup.ts +463 -0
  19. package/cli/validate-migration.ts +200 -0
  20. package/cli/wrangler-migration.jsonc +28 -0
  21. package/db/adapter.ts +166 -0
  22. package/db/analytics_engine/client.ts +0 -0
  23. package/db/analytics_engine/sites.ts +0 -0
  24. package/db/client.ts +16 -0
  25. package/db/d1/client.ts +8 -0
  26. package/db/d1/drizzle.config.ts +35 -0
  27. package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
  28. package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
  29. package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
  30. package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
  31. package/db/d1/migrations/0004_mute_stardust.sql +1 -0
  32. package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
  33. package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
  34. package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
  35. package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
  36. package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
  37. package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
  38. package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
  39. package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
  40. package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
  41. package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
  42. package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
  43. package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
  44. package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
  45. package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
  46. package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
  47. package/db/d1/migrations/meta/_journal.json +76 -0
  48. package/db/d1/schema.ts +407 -0
  49. package/db/d1/sites.ts +374 -0
  50. package/db/d1/teamAiUsage.ts +101 -0
  51. package/db/d1/teams.ts +127 -0
  52. package/db/durable/drizzle.config.ts +8 -0
  53. package/db/durable/durableObjectClient.ts +480 -0
  54. package/db/durable/events.ts +100 -0
  55. package/db/durable/migrations/0000_fair_bucky.sql +38 -0
  56. package/db/durable/migrations/meta/0000_snapshot.json +278 -0
  57. package/db/durable/migrations/meta/_journal.json +13 -0
  58. package/db/durable/migrations/migrations.js +10 -0
  59. package/db/durable/schema.ts +5 -0
  60. package/db/durable/siteDurableObject.ts +1352 -0
  61. package/db/durable/types.ts +53 -0
  62. package/db/postgres/client.ts +13 -0
  63. package/db/postgres/drizzle.config.ts +12 -0
  64. package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
  65. package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
  66. package/db/postgres/migrations/meta/_journal.json +13 -0
  67. package/db/postgres/schema.ts +145 -0
  68. package/db/postgres/sites.ts +118 -0
  69. package/db/tranformReports.ts +595 -0
  70. package/db/types.ts +55 -0
  71. package/endpoints/api_worker.tsx +1854 -0
  72. package/endpoints/site_do_worker.ts +11 -0
  73. package/index.d.ts +63 -0
  74. package/index.ts +83 -0
  75. package/lib/auth.ts +279 -0
  76. package/lib/geojson/world_countries.json +45307 -0
  77. package/lib/random_name.ts +41 -0
  78. package/lib/sendMail.ts +252 -0
  79. package/package.json +142 -0
  80. package/public/favicon.ico +0 -0
  81. package/public/images/android-chrome-192x192.png +0 -0
  82. package/public/images/android-chrome-512x512.png +0 -0
  83. package/public/images/apple-touch-icon.png +0 -0
  84. package/public/images/favicon-16x16.png +0 -0
  85. package/public/images/favicon-32x32.png +0 -0
  86. package/public/images/lytx_dark_dashboard.png +0 -0
  87. package/public/images/lytx_light_dashboard.png +0 -0
  88. package/public/images/safari-pinned-tab.svg +4 -0
  89. package/public/logo.png +0 -0
  90. package/public/site.webmanifest +26 -0
  91. package/public/sw.js +107 -0
  92. package/src/Document.tsx +86 -0
  93. package/src/api/ai_api.ts +1156 -0
  94. package/src/api/authMiddleware.ts +45 -0
  95. package/src/api/auth_api.ts +465 -0
  96. package/src/api/event_labels_api.ts +193 -0
  97. package/src/api/events_api.ts +210 -0
  98. package/src/api/queueWorker.ts +303 -0
  99. package/src/api/reports_api.ts +278 -0
  100. package/src/api/seed_api.ts +288 -0
  101. package/src/api/sites_api.ts +904 -0
  102. package/src/api/tag_api.ts +458 -0
  103. package/src/api/tag_api_v2.ts +289 -0
  104. package/src/api/team_api.ts +456 -0
  105. package/src/app/Dashboard.tsx +1339 -0
  106. package/src/app/Events.tsx +974 -0
  107. package/src/app/Explore.tsx +312 -0
  108. package/src/app/Layout.tsx +58 -0
  109. package/src/app/Settings.tsx +1302 -0
  110. package/src/app/components/DashboardCard.tsx +118 -0
  111. package/src/app/components/EditableCell.tsx +123 -0
  112. package/src/app/components/EventForm.tsx +93 -0
  113. package/src/app/components/MarketingFooter.tsx +49 -0
  114. package/src/app/components/MarketingNav.tsx +150 -0
  115. package/src/app/components/Nav.tsx +755 -0
  116. package/src/app/components/NewSiteSetup.tsx +298 -0
  117. package/src/app/components/SQLEditor.tsx +740 -0
  118. package/src/app/components/SiteSelector.tsx +126 -0
  119. package/src/app/components/SiteTag.tsx +42 -0
  120. package/src/app/components/SiteTagInstallCard.tsx +241 -0
  121. package/src/app/components/WorldMapCard.tsx +337 -0
  122. package/src/app/components/charts/ChartComponents.tsx +1481 -0
  123. package/src/app/components/charts/EventFunnel.tsx +45 -0
  124. package/src/app/components/charts/EventSummary.tsx +194 -0
  125. package/src/app/components/charts/SankeyFlows.tsx +72 -0
  126. package/src/app/components/marketing/CheckIcon.tsx +16 -0
  127. package/src/app/components/marketing/MarketingLayout.tsx +23 -0
  128. package/src/app/components/marketing/SectionHeading.tsx +35 -0
  129. package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
  130. package/src/app/components/reports/CreateReportStarter.tsx +74 -0
  131. package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
  132. package/src/app/components/reports/DashboardToolbar.tsx +154 -0
  133. package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
  134. package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
  135. package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
  136. package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
  137. package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
  138. package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
  139. package/src/app/components/reports/custom/chartPalettes.ts +18 -0
  140. package/src/app/components/reports/custom/types.ts +50 -0
  141. package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
  142. package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
  143. package/src/app/components/ui/AlertBanner.tsx +101 -0
  144. package/src/app/components/ui/Button.tsx +55 -0
  145. package/src/app/components/ui/Card.tsx +80 -0
  146. package/src/app/components/ui/Input.tsx +72 -0
  147. package/src/app/components/ui/Link.tsx +23 -0
  148. package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
  149. package/src/app/components/ui/ThemeToggle.tsx +54 -0
  150. package/src/app/constants.ts +6 -0
  151. package/src/app/headers.ts +33 -0
  152. package/src/app/providers/AuthProvider.tsx +189 -0
  153. package/src/app/providers/ClientProviders.tsx +18 -0
  154. package/src/app/providers/QueryProvider.tsx +23 -0
  155. package/src/app/providers/ThemeProvider.tsx +88 -0
  156. package/src/app/utils/chartThemes.ts +146 -0
  157. package/src/app/utils/keybinds.ts +96 -0
  158. package/src/app/utils/media.tsx +24 -0
  159. package/src/client.tsx +114 -0
  160. package/src/config/createLytxAppConfig.ts +252 -0
  161. package/src/config/resourceNames.ts +88 -0
  162. package/src/db/index.ts +67 -0
  163. package/src/index.css +285 -0
  164. package/src/lib/featureFlags.ts +69 -0
  165. package/src/pages/GetStarted.tsx +290 -0
  166. package/src/pages/Home.tsx +268 -0
  167. package/src/pages/Login.tsx +283 -0
  168. package/src/pages/PrivacyPolicy.tsx +120 -0
  169. package/src/pages/Signup.tsx +267 -0
  170. package/src/pages/TermsOfService.tsx +126 -0
  171. package/src/pages/VerifyEmail.tsx +56 -0
  172. package/src/session/durableObject.ts +7 -0
  173. package/src/session/siteSchema.ts +86 -0
  174. package/src/session/types.ts +36 -0
  175. package/src/templates/README.md +80 -0
  176. package/src/templates/cleanFunctions.js +44 -0
  177. package/src/templates/embedFunctions.js +52 -0
  178. package/src/templates/lytx-shared.ts +662 -0
  179. package/src/templates/lytxpixel-core.ts +144 -0
  180. package/src/templates/lytxpixel.ts +267 -0
  181. package/src/templates/lytxpixelBrowser.js +634 -0
  182. package/src/templates/lytxpixelBrowser.mjs +634 -0
  183. package/src/templates/parseData.js +12 -0
  184. package/src/templates/script.ts +31 -0
  185. package/src/templates/template.tsx +50 -0
  186. package/src/templates/test.js +3 -0
  187. package/src/templates/trackWebEvents.ts +177 -0
  188. package/src/templates/vendors/clickcease.ts +8 -0
  189. package/src/templates/vendors/google.ts +174 -0
  190. package/src/templates/vendors/linkedin.ts +23 -0
  191. package/src/templates/vendors/meta.ts +56 -0
  192. package/src/templates/vendors/quantcast.ts +22 -0
  193. package/src/templates/vendors/simplfi.ts +7 -0
  194. package/src/types/app-context.ts +16 -0
  195. package/src/utilities/dashboardParams.ts +188 -0
  196. package/src/utilities/dashboardQueries.ts +537 -0
  197. package/src/utilities/dashboardTransforms.ts +167 -0
  198. package/src/utilities/dataValidation.ts +414 -0
  199. package/src/utilities/detector.ts +73 -0
  200. package/src/utilities/encrypt.ts +103 -0
  201. package/src/utilities/index.ts +13 -0
  202. package/src/utilities/parser.ts +117 -0
  203. package/src/utilities/performanceMonitoring.ts +570 -0
  204. package/src/utilities/route_interuptors.ts +24 -0
  205. package/src/worker.tsx +675 -0
  206. package/tsconfig.json +78 -0
  207. package/types/env.d.ts +16 -0
  208. package/types/rw.d.ts +7 -0
  209. package/types/shims.d.ts +53 -0
  210. package/types/vite.d.ts +19 -0
  211. package/vite/vite-plugin-pixel-bundle.ts +126 -0
  212. package/vite.config.ts +53 -0
  213. package/worker-configuration.d.ts +8401 -0
@@ -0,0 +1,1339 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useCallback,
9
+ useContext,
10
+ Suspense,
11
+ } from "react";
12
+ import { useQuery } from "@tanstack/react-query";
13
+ import { ResponsiveBar } from "@nivo/bar";
14
+ import { AuthContext } from "@/app/providers/AuthProvider";
15
+ import { SiteTagInstallCard } from "@/app/components/SiteTagInstallCard";
16
+ import { useTheme } from "@/app/providers/ThemeProvider";
17
+ import { AlertBanner } from "@/app/components/ui/AlertBanner";
18
+ import { DashboardToolbar } from "@/app/components/reports/DashboardToolbar";
19
+ import type { ReportBuilderMenuActiveId } from "@/app/components/ui/ReportBuilderMenu";
20
+ import { EventSummaryTable } from "@components/charts/EventSummary";
21
+ import { ChartComponent, ChartSkeleton, CardTabs, TableComponent, getCountryFlagIcon, getBrowserTimeZone, getDateStringInTimeZone, isValidTimeZone, ScorecardSkeleton, SkeletonBlock, DashboardFilters, DashboardNotice, Scorecard } from "@/app/components/charts/ChartComponents";
22
+
23
+ import { useMediaQuery } from "@/app/utils/media";
24
+ import {
25
+ type DeviceGeoData,
26
+ type NivoBarChartData,
27
+ type NivoLineChartData,
28
+ type NivoPieChartData,
29
+ type TableComponentProps,
30
+ TopSourcesData,
31
+ BrowserData,
32
+ DashboardResponseData,
33
+ } from "@db/tranformReports";
34
+
35
+ import { chartColors } from "@/app/utils/chartThemes";
36
+ import { DashboardCard } from "@components/DashboardCard";
37
+ import { WorldMapCard } from "@components/WorldMapCard";
38
+ import { useDashboardToolbarControls } from "@/app/components/reports/useDashboardToolbarControls";
39
+ import type { EventLabelSelect } from "@db/d1/schema";
40
+ import { EventTypesFunnel } from "@/app/components/charts/EventFunnel";
41
+ import type { ToolbarSiteOption } from "@/app/components/reports/DashboardToolbar";
42
+
43
+ // Props for the main DashboardPage (now empty as data is fetched internally)
44
+ export interface DashboardPageProps {
45
+ PageViewsData?: NivoLineChartData;
46
+ ReferrersData?: NivoPieChartData;
47
+ EventTypesData?: TableComponentProps["tableData"];
48
+ DeviceGeoData?: DeviceGeoData;
49
+ TopPagesData?: NivoBarChartData;
50
+ TopSourcesData?: TopSourcesData;
51
+ BrowserData?: BrowserData;
52
+ EventSummary?: DashboardResponseData["EventSummary"];
53
+ DateRange?: {
54
+ auto: "7 days";
55
+ };
56
+ activeReportBuilderItemId?: ReportBuilderMenuActiveId;
57
+ reportBuilderEnabled?: boolean;
58
+ askAiEnabled?: boolean;
59
+ initialToolbarSites?: ToolbarSiteOption[];
60
+ initialToolbarSiteId?: number | null;
61
+ initialDashboardData?: DashboardResponseData | null;
62
+ initialDashboardDateRange?: {
63
+ start: string;
64
+ end: string;
65
+ preset: "Today";
66
+ };
67
+ initialTimezone?: string | null;
68
+ }
69
+ const getBrowserIcon = (name: string | null | undefined) => {
70
+ const value = typeof name === "string" ? name.toLowerCase() : "";
71
+
72
+ const iconMap = [
73
+ { match: ["edge"], label: "E", classes: "bg-teal-500/15 text-teal-300 ring-1 ring-teal-400/40" },
74
+ { match: ["opera"], label: "O", classes: "bg-red-500/15 text-red-300 ring-1 ring-red-400/40" },
75
+ { match: ["firefox", "fxios"], label: "F", classes: "bg-orange-500/15 text-orange-300 ring-1 ring-orange-400/40" },
76
+ { match: ["safari"], label: "S", classes: "bg-sky-500/15 text-sky-300 ring-1 ring-sky-400/40" },
77
+ { match: ["chrome", "chromium", "crios"], label: "C", classes: "bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-400/40" },
78
+ { match: ["brave"], label: "B", classes: "bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/40" },
79
+ { match: ["ie", "internet explorer"], label: "IE", classes: "bg-blue-500/15 text-blue-300 ring-1 ring-blue-400/40" },
80
+ ];
81
+
82
+ const matched = iconMap.find(({ match }) => match.some((token) => value.includes(token)));
83
+
84
+ if (!matched) {
85
+ return null;
86
+ }
87
+
88
+ return (
89
+ <span
90
+ aria-hidden="true"
91
+ className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${matched.classes}`}
92
+ >
93
+ {matched.label}
94
+ </span>
95
+ );
96
+ };
97
+
98
+ const getBrowserLogo = (name: string | null | undefined) => {
99
+ const value = typeof name === "string" ? name.toLowerCase() : "";
100
+
101
+ if (value.includes("edge")) {
102
+ return (
103
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
104
+ <circle cx="32" cy="32" r="30" fill="#0B5CAB" />
105
+ <path
106
+ d="M50 32c0-10-8-18-18-18-7 0-13 4-16 10 3-2 6-3 10-3 9 0 16 6 16 13 0 3-1 6-3 8 7-3 11-9 11-10z"
107
+ fill="#22D3EE"
108
+ />
109
+ <path
110
+ d="M14 34c1 12 11 20 22 20 9 0 16-5 20-12-3 1-6 2-10 2-10 0-18-6-18-14 0-4 2-7 4-9-9 1-16 7-18 13z"
111
+ fill="#38BDF8"
112
+ />
113
+ </svg>
114
+ );
115
+ }
116
+
117
+ if (value.includes("opera")) {
118
+ return (
119
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
120
+ <circle cx="32" cy="32" r="26" fill="none" stroke="#FF1B2D" strokeWidth="10" />
121
+ <circle cx="32" cy="32" r="14" fill="none" stroke="#FF6B6B" strokeWidth="4" opacity="0.4" />
122
+ </svg>
123
+ );
124
+ }
125
+
126
+ if (value.includes("firefox") || value.includes("fxios")) {
127
+ return (
128
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
129
+ <circle cx="32" cy="32" r="30" fill="#FF7139" />
130
+ <path
131
+ d="M46 18c-6 1-10 5-12 10 6 2 10 7 10 13 0 8-6 14-14 14-6 0-12-3-15-8 2 8 10 15 20 15 11 0 20-9 20-20 0-9-6-18-9-24z"
132
+ fill="#7C3AED"
133
+ />
134
+ <path
135
+ d="M24 24c-2 4-1 8 2 11-4 1-6 4-6 7 0 6 6 10 12 8-4-1-7-4-7-8 0-4 3-7 7-8-4-2-7-5-8-10z"
136
+ fill="#F97316"
137
+ />
138
+ </svg>
139
+ );
140
+ }
141
+
142
+ if (value.includes("safari")) {
143
+ return (
144
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
145
+ <circle cx="32" cy="32" r="30" fill="#0EA5E9" />
146
+ <circle cx="32" cy="32" r="22" fill="none" stroke="#E0F2FE" strokeWidth="3" />
147
+ <path d="M32 14l6 18-6-3-6 3 6-18z" fill="#F87171" />
148
+ <path d="M32 50l-6-18 6 3 6-3-6 18z" fill="#F8FAFC" />
149
+ <circle cx="32" cy="32" r="3" fill="#F8FAFC" />
150
+ </svg>
151
+ );
152
+ }
153
+
154
+ if (value.includes("chrome") || value.includes("chromium") || value.includes("crios")) {
155
+ return (
156
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
157
+ <path d="M32 32L32 2A30 30 0 0 1 58 47Z" fill="#DB4437" />
158
+ <path d="M32 32L58 47A30 30 0 0 1 6 47Z" fill="#0F9D58" />
159
+ <path d="M32 32L6 47A30 30 0 0 1 32 2Z" fill="#F4B400" />
160
+ <circle cx="32" cy="32" r="12" fill="#4285F4" />
161
+ <circle cx="32" cy="32" r="5" fill="#E6F0FF" />
162
+ </svg>
163
+ );
164
+ }
165
+
166
+ if (value.includes("brave")) {
167
+ return (
168
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
169
+ <path
170
+ d="M16 10h32l6 10-4 24-18 10-18-10-4-24 6-10z"
171
+ fill="#F97316"
172
+ />
173
+ <path d="M22 20h20l4 6-3 16-11 6-11-6-3-16 4-6z" fill="#FDBA74" />
174
+ </svg>
175
+ );
176
+ }
177
+
178
+ return null;
179
+ };
180
+
181
+ const getOsIcon = (name: string | null | undefined) => {
182
+ const value = typeof name === "string" ? name.toLowerCase() : "";
183
+
184
+ const iconMap = [
185
+ { match: ["windows"], label: "W", classes: "bg-blue-500/15 text-blue-300 ring-1 ring-blue-400/40" },
186
+ { match: ["macos", "mac os", "os x", "mac"], label: "M", classes: "bg-slate-500/20 text-slate-200 ring-1 ring-slate-400/40" },
187
+ { match: ["ios", "ipados"], label: "i", classes: "bg-slate-500/20 text-slate-200 ring-1 ring-slate-400/40" },
188
+ { match: ["android"], label: "A", classes: "bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-400/40" },
189
+ { match: ["ubuntu"], label: "U", classes: "bg-orange-500/15 text-orange-300 ring-1 ring-orange-400/40" },
190
+ { match: ["linux"], label: "L", classes: "bg-yellow-500/15 text-yellow-300 ring-1 ring-yellow-400/40" },
191
+ { match: ["chrome os", "chromebook"], label: "C", classes: "bg-sky-500/15 text-sky-300 ring-1 ring-sky-400/40" },
192
+ ];
193
+
194
+ const matched = iconMap.find(({ match }) => match.some((token) => value.includes(token)));
195
+
196
+ if (!matched) {
197
+ return null;
198
+ }
199
+
200
+ return (
201
+ <span
202
+ aria-hidden="true"
203
+ className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${matched.classes}`}
204
+ >
205
+ {matched.label}
206
+ </span>
207
+ );
208
+ };
209
+
210
+ const getOsLogo = (name: string | null | undefined) => {
211
+ const value = typeof name === "string" ? name.toLowerCase() : "";
212
+
213
+ if (value.includes("windows")) {
214
+ return (
215
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
216
+ <rect x="6" y="8" width="24" height="22" fill="#00A4EF" />
217
+ <rect x="34" y="8" width="24" height="22" fill="#00A4EF" />
218
+ <rect x="6" y="34" width="24" height="22" fill="#00A4EF" />
219
+ <rect x="34" y="34" width="24" height="22" fill="#00A4EF" />
220
+ </svg>
221
+ );
222
+ }
223
+
224
+ if (value.includes("ios") || value.includes("ipados") || value.includes("macos") || value.includes("mac os") || value.includes("os x")) {
225
+ return (
226
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
227
+ <path
228
+ d="M32 20c-4-5-11-4-14 1-3 6-1 15 3 21 3 5 7 9 11 9 3 0 4-2 7-2 3 0 4 2 7 2 4 0 8-4 11-9 4-7 6-15 3-21-3-5-10-6-14-1-2 2-4 3-7 3-3 0-5-1-7-3z"
229
+ fill="#E5E7EB"
230
+ />
231
+ <path
232
+ d="M39 10c2-3 5-5 9-6-1 4-3 7-6 9-3 2-6 3-9 2 0-2 3-4 6-5z"
233
+ fill="#E5E7EB"
234
+ />
235
+ </svg>
236
+ );
237
+ }
238
+
239
+ if (value.includes("android")) {
240
+ return (
241
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
242
+ <rect x="14" y="20" width="36" height="26" rx="8" fill="#3DDC84" />
243
+ <circle cx="26" cy="32" r="2" fill="#0F172A" />
244
+ <circle cx="38" cy="32" r="2" fill="#0F172A" />
245
+ <line x1="22" y1="20" x2="16" y2="12" stroke="#3DDC84" strokeWidth="4" strokeLinecap="round" />
246
+ <line x1="42" y1="20" x2="48" y2="12" stroke="#3DDC84" strokeWidth="4" strokeLinecap="round" />
247
+ </svg>
248
+ );
249
+ }
250
+
251
+ if (value.includes("ubuntu")) {
252
+ return (
253
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
254
+ <circle cx="32" cy="32" r="22" fill="none" stroke="#E95420" strokeWidth="6" />
255
+ <circle cx="32" cy="10" r="4" fill="#E95420" />
256
+ <circle cx="12" cy="42" r="4" fill="#E95420" />
257
+ <circle cx="52" cy="42" r="4" fill="#E95420" />
258
+ </svg>
259
+ );
260
+ }
261
+
262
+ if (value.includes("linux")) {
263
+ return (
264
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
265
+ <circle cx="32" cy="18" r="8" fill="#111827" />
266
+ <ellipse cx="32" cy="40" rx="14" ry="18" fill="#111827" />
267
+ <ellipse cx="32" cy="42" rx="8" ry="12" fill="#F8FAFC" />
268
+ <circle cx="28" cy="18" r="2" fill="#F8FAFC" />
269
+ <circle cx="36" cy="18" r="2" fill="#F8FAFC" />
270
+ <path d="M32 22l4 4h-8l4-4z" fill="#F59E0B" />
271
+ </svg>
272
+ );
273
+ }
274
+
275
+ if (value.includes("chrome os") || value.includes("chromebook")) {
276
+ return (
277
+ <svg aria-hidden="true" viewBox="0 0 64 64" className="h-5 w-5">
278
+ <path d="M32 32L32 2A30 30 0 0 1 58 47Z" fill="#DB4437" />
279
+ <path d="M32 32L58 47A30 30 0 0 1 6 47Z" fill="#0F9D58" />
280
+ <path d="M32 32L6 47A30 30 0 0 1 32 2Z" fill="#F4B400" />
281
+ <circle cx="32" cy="32" r="12" fill="#4285F4" />
282
+ <circle cx="32" cy="32" r="5" fill="#E6F0FF" />
283
+ </svg>
284
+ );
285
+ }
286
+
287
+ return null;
288
+ };
289
+
290
+ const getOsDotClass = (name: string | null | undefined) => {
291
+ const value = typeof name === "string" ? name.toLowerCase() : "";
292
+
293
+ if (value.includes("windows")) return "bg-blue-600";
294
+ if (value.includes("macos") || value.includes("mac os") || value.includes("os x")) return "bg-gray-500";
295
+ if (value.includes("ios") || value.includes("ipados")) return "bg-slate-400";
296
+ if (value.includes("ubuntu")) return "bg-orange-500";
297
+ if (value.includes("linux")) return "bg-yellow-500";
298
+ if (value.includes("android")) return "bg-green-600";
299
+ if (value.includes("chrome os") || value.includes("chromebook")) return "bg-sky-500";
300
+
301
+ return "bg-green-600";
302
+ };
303
+
304
+ const truncateAxisLabel = (value: unknown, maxLength: number) => {
305
+ const label = String(value ?? "").trim();
306
+ if (label.length <= maxLength) return label;
307
+ return `${label.slice(0, Math.max(1, maxLength - 3))}...`;
308
+ };
309
+
310
+ const dashboardChartTitles = {
311
+ pageViews: "Page Views",
312
+ topReferrers: "Top Referrers",
313
+ deviceTypes: "Device Types",
314
+ topSources: "Top Sources",
315
+ topPages: "Top Pages",
316
+ locations: "Locations",
317
+ devices: "Devices",
318
+ } as const;
319
+
320
+ const GEO_LIST_VISIBLE_ROWS = 10;
321
+ const GEO_LIST_ROW_HEIGHT_PX = 36;
322
+ const GEO_LIST_MAX_HEIGHT = GEO_LIST_VISIBLE_ROWS * GEO_LIST_ROW_HEIGHT_PX;
323
+
324
+ // --- DashboardPage (fetches its own data) ---
325
+ export function DashboardPage(props: DashboardPageProps) {
326
+ const isSmallScreen = useMediaQuery("(max-width: 640px)");
327
+ const {
328
+ PageViewsData,
329
+ EventTypesData,
330
+ DeviceGeoData,
331
+ ReferrersData,
332
+ EventSummary,
333
+ activeReportBuilderItemId = "create-report",
334
+ reportBuilderEnabled = false,
335
+ askAiEnabled = true,
336
+ initialToolbarSites = [],
337
+ initialToolbarSiteId = null,
338
+ initialDashboardData = null,
339
+ initialDashboardDateRange,
340
+ initialTimezone = null,
341
+ } = props;
342
+
343
+ const { data: session, isPending: isSessionLoading, current_site } = useContext(
344
+ AuthContext,
345
+ ) || { data: null, isPending: true };
346
+
347
+ const [browserTimezone, setBrowserTimezone] = useState<string>(() =>
348
+ isValidTimeZone(initialTimezone) ? initialTimezone : "UTC",
349
+ );
350
+
351
+ useEffect(() => {
352
+ setBrowserTimezone(getBrowserTimeZone());
353
+ }, []);
354
+
355
+ const savedTimezone = session?.timezone;
356
+ const effectiveTimezone =
357
+ isValidTimeZone(savedTimezone) ? savedTimezone : browserTimezone;
358
+
359
+ const { theme } = useTheme();
360
+
361
+ const currentSiteTag =
362
+ !isSessionLoading && session && current_site && session.userSites
363
+ ? session.userSites.find((site) => site.site_id === current_site.id)
364
+ : null;
365
+
366
+ // const [current_site, setCurrentSite] = useState<{ name: string, id: number } | undefined>();
367
+ const [filters, setFilters] = useState<DashboardFilters>({
368
+ dateRange: {
369
+ start: initialDashboardDateRange?.start ?? "",
370
+ end: initialDashboardDateRange?.end ?? "",
371
+ preset: initialDashboardDateRange?.preset ?? "Today",
372
+ },
373
+ deviceType: undefined,
374
+ country: undefined,
375
+ city: undefined,
376
+ region: undefined,
377
+ source: undefined,
378
+ pageUrl: undefined,
379
+ eventName: undefined,
380
+ siteId: initialToolbarSiteId ? String(initialToolbarSiteId) : undefined,
381
+ });
382
+
383
+ const [isClientReady, setIsClientReady] = useState(
384
+ Boolean(initialDashboardDateRange?.start && initialDashboardDateRange?.end),
385
+ );
386
+ const hasInitializedDateRange = useRef(
387
+ Boolean(initialDashboardDateRange?.start && initialDashboardDateRange?.end),
388
+ );
389
+ const hasConsumedInitialDashboardData = useRef(false);
390
+
391
+ useEffect(() => {
392
+ if (isSessionLoading || hasInitializedDateRange.current) return;
393
+
394
+ const today = getDateStringInTimeZone(new Date(), effectiveTimezone);
395
+
396
+ setFilters((prevFilters) => ({
397
+ ...prevFilters,
398
+ dateRange: {
399
+ start: today,
400
+ end: today,
401
+ preset: "Today",
402
+ },
403
+ }));
404
+ hasInitializedDateRange.current = true;
405
+ setIsClientReady(true);
406
+ }, [effectiveTimezone, isSessionLoading]);
407
+
408
+ useEffect(() => {
409
+ const nextSiteId =
410
+ current_site?.id?.toString()
411
+ ?? session?.userSites?.[0]?.site_id?.toString()
412
+ ?? initialToolbarSiteId?.toString();
413
+
414
+ if (!nextSiteId) return;
415
+
416
+ setFilters((prevFilters) => ({
417
+ ...prevFilters,
418
+ siteId: nextSiteId,
419
+ }));
420
+ }, [current_site?.id, initialToolbarSiteId, session?.userSites]);
421
+
422
+ const effectiveSiteId =
423
+ current_site?.id
424
+ ?? (filters.siteId ? Number(filters.siteId) : null)
425
+ ?? initialToolbarSiteId
426
+ ?? null;
427
+
428
+ const shouldUseInitialDashboardData =
429
+ !hasConsumedInitialDashboardData.current
430
+ &&
431
+ Boolean(initialDashboardData)
432
+ && effectiveSiteId === initialToolbarSiteId
433
+ && filters.dateRange.preset === "Today"
434
+ && !filters.deviceType
435
+ && !filters.country
436
+ && !filters.city
437
+ && !filters.region
438
+ && !filters.source
439
+ && !filters.pageUrl
440
+ && !filters.eventName;
441
+
442
+ const isRealtimePreset =
443
+ filters.dateRange.preset === "Today"
444
+ || filters.dateRange.preset === "Last hour"
445
+ || filters.dateRange.preset === "Last 30 min";
446
+
447
+ const dashboardStaleTime = isRealtimePreset ? 0 : 5 * 60 * 1000;
448
+ const dashboardGcTime = isRealtimePreset ? 0 : 10 * 60 * 1000;
449
+
450
+ useEffect(() => {
451
+ hasConsumedInitialDashboardData.current = true;
452
+ }, []);
453
+
454
+ const [notice, setNotice] = useState<DashboardNotice | null>(null);
455
+
456
+ useEffect(() => {
457
+ if (!notice) return;
458
+
459
+ const handle = window.setTimeout(() => {
460
+ setNotice(null);
461
+ }, 5000);
462
+
463
+ return () => {
464
+ window.clearTimeout(handle);
465
+ };
466
+ }, [notice]);
467
+
468
+ const notify = useCallback((nextNotice: DashboardNotice) => {
469
+ setNotice(nextNotice);
470
+ }, []);
471
+
472
+ const getRequestIdFromErrorMessage = (message: string): string | null => {
473
+ const match = message.match(/requestId\s*[:=]\s*([a-f0-9-]{8,})/i);
474
+ return match ? match[1] : null;
475
+ };
476
+
477
+ const getFriendlyDashboardErrorMessage = (error: unknown): string => {
478
+ if (!(error instanceof Error)) {
479
+ return "We couldn’t load the dashboard. Please try again.";
480
+ }
481
+
482
+ const message = error.message.trim();
483
+
484
+ if (message.toLowerCase().includes("no site selected")) {
485
+ return "Select a site to load dashboard metrics.";
486
+ }
487
+
488
+ if (message.toLowerCase().includes("failed to fetch")) {
489
+ return "Network error while loading metrics. Check your connection and try again.";
490
+ }
491
+
492
+ if (message.toLowerCase().includes("site not found")) {
493
+ return "This site isn’t available for your account. Try selecting a different site.";
494
+ }
495
+
496
+ if (message.toLowerCase().includes("no data found")) {
497
+ return "No matching events for the selected filters.";
498
+ }
499
+
500
+ return message || "We couldn’t load the dashboard. Please try again.";
501
+ };
502
+
503
+ // React Query for dashboard data fetching
504
+ const {
505
+ data: apiData,
506
+ error: queryError,
507
+ isLoading,
508
+ isFetching,
509
+ refetch: refetchData,
510
+ } = useQuery({
511
+ queryKey: [
512
+ "dashboardData",
513
+ effectiveSiteId,
514
+ filters.dateRange,
515
+ filters.deviceType,
516
+ filters.country,
517
+ filters.city,
518
+ filters.region,
519
+ filters.source,
520
+ filters.pageUrl,
521
+ filters.eventName,
522
+ effectiveTimezone,
523
+ ],
524
+ queryFn: async () => {
525
+ if (!effectiveSiteId) {
526
+ throw new Error("No site selected");
527
+ }
528
+
529
+ try {
530
+ const response = await fetch("/api/dashboard/data", {
531
+ method: "POST",
532
+ headers: { "Content-Type": "application/json" },
533
+ body: JSON.stringify({
534
+ site_id: effectiveSiteId,
535
+ date_start: filters.dateRange.start,
536
+ date_end: filters.dateRange.end,
537
+ device_type: filters.deviceType,
538
+ country: filters.country,
539
+ city: filters.city,
540
+ region: filters.region,
541
+ source: filters.source,
542
+ page_url: filters.pageUrl,
543
+ event_name: filters.eventName,
544
+ timezone: effectiveTimezone,
545
+ }),
546
+ });
547
+
548
+ let payload: unknown = null;
549
+ try {
550
+ payload = await response.json();
551
+ } catch {
552
+ payload = null;
553
+ }
554
+
555
+ if (!response.ok) {
556
+ const messageFromApi =
557
+ typeof payload === "object" && payload !== null && "error" in payload
558
+ ? String((payload as { error: unknown }).error)
559
+ : null;
560
+
561
+ const requestIdFromApi =
562
+ typeof payload === "object" &&
563
+ payload !== null &&
564
+ "requestId" in payload &&
565
+ typeof (payload as { requestId?: unknown }).requestId === "string"
566
+ ? (payload as { requestId: string }).requestId
567
+ : null;
568
+
569
+ const baseMessage =
570
+ messageFromApi ||
571
+ `Failed to load dashboard data (HTTP ${response.status})`;
572
+
573
+ throw new Error(
574
+ requestIdFromApi ? `${baseMessage} (requestId: ${requestIdFromApi})` : baseMessage,
575
+ );
576
+ }
577
+
578
+ return payload as DashboardResponseData;
579
+ } catch (error) {
580
+ console.error("Dashboard data fetch error:", error);
581
+ throw error;
582
+ }
583
+ },
584
+ enabled: isClientReady && Boolean(effectiveSiteId) && Boolean(filters.dateRange.start) && Boolean(filters.dateRange.end),
585
+ initialData: shouldUseInitialDashboardData ? initialDashboardData ?? undefined : undefined,
586
+ initialDataUpdatedAt: shouldUseInitialDashboardData ? Date.now() : undefined,
587
+ refetchOnMount: isRealtimePreset ? "always" : false,
588
+ placeholderData: (previousData) => previousData,
589
+ staleTime: dashboardStaleTime,
590
+ gcTime: dashboardGcTime,
591
+ });
592
+
593
+ const labelsQuery = useQuery<EventLabelSelect[], Error>({
594
+ queryKey: ["event-labels", effectiveSiteId],
595
+ queryFn: async () => {
596
+ if (!effectiveSiteId) return [];
597
+ const response = await fetch(`/api/event-labels?site_id=${effectiveSiteId}`);
598
+ if (!response.ok) throw new Error("Failed to fetch event labels");
599
+ return response.json();
600
+ },
601
+ enabled: Boolean(effectiveSiteId),
602
+ staleTime: 5 * 60 * 1000,
603
+ gcTime: 10 * 60 * 1000,
604
+ });
605
+
606
+ const eventLabelsMap = useMemo(() => {
607
+ const map = new Map<string, string>();
608
+ if (labelsQuery.data) {
609
+ for (const label of labelsQuery.data) {
610
+ map.set(label.event_name, label.label);
611
+ }
612
+ }
613
+ return map;
614
+ }, [labelsQuery.data]);
615
+
616
+ const dashboardData = useMemo(() => ({
617
+ topPagesData: apiData?.TopPagesData || props.TopPagesData,
618
+ topSourcesData: apiData?.TopSourcesData || props.TopSourcesData,
619
+ browserData: apiData?.BrowserData || props.BrowserData,
620
+ osData: apiData?.OSData || [],
621
+ pageViewsData: apiData?.PageViewsData || PageViewsData,
622
+ referrersData: apiData?.ReferrersData || ReferrersData,
623
+ eventTypesData: apiData?.EventTypesData || EventTypesData,
624
+ deviceGeoData: apiData?.DeviceGeoData || DeviceGeoData,
625
+ eventSummary: apiData?.EventSummary || EventSummary,
626
+ regions: apiData?.Regions || [],
627
+ }), [apiData, props.TopPagesData, props.TopSourcesData, props.BrowserData, PageViewsData, ReferrersData, EventTypesData, DeviceGeoData, EventSummary]);
628
+
629
+ // Tab state management
630
+ const [topSourcesTab, setTopSourcesTab] = useState<"Sources" | "Referrers">(
631
+ "Sources",
632
+ );
633
+
634
+ const [locationsTab, setLocationsTab] = useState<"Countries" | "Cities">(
635
+ "Countries",
636
+ );
637
+ const [devicesTab, setDevicesTab] = useState<"Browser" | "OS">("Browser");
638
+
639
+ const aggregatedCountries = useMemo(() => {
640
+ const countryRows = apiData?.Countries;
641
+ if (countryRows && countryRows.length > 0) {
642
+ return countryRows
643
+ .filter((row) => typeof row?.id === "string" && row.id.length > 0)
644
+ .map((row) => [row.id, row.value] as [string, number]);
645
+ }
646
+
647
+ const geoRows = dashboardData.deviceGeoData?.geoData?.rows;
648
+ if (!geoRows || geoRows.length === 0) return [];
649
+ const countryMap = new Map<string, number>();
650
+ geoRows.forEach((row) => {
651
+ const [country, , count] = row as [string, string, number];
652
+ countryMap.set(country, (countryMap.get(country) || 0) + count);
653
+ });
654
+ return Array.from(countryMap.entries()).toSorted((a, b) => b[1] - a[1]);
655
+ }, [apiData?.Countries, dashboardData.deviceGeoData?.geoData?.rows]);
656
+
657
+ const mapCountries = useMemo(() => {
658
+ const uniqueRows = apiData?.CountryUniques;
659
+ if (uniqueRows && uniqueRows.length > 0) {
660
+ return uniqueRows
661
+ .filter((row) => typeof row?.id === "string" && row.id.length > 0)
662
+ .map((row) => [row.id, row.value] as [string, number]);
663
+ }
664
+
665
+ return aggregatedCountries;
666
+ }, [apiData?.CountryUniques, aggregatedCountries]);
667
+
668
+ const rankedGeoCities = useMemo(() => {
669
+ const rows = dashboardData.deviceGeoData?.geoData?.rows || [];
670
+ return rows
671
+ .map((row) => {
672
+ const [country, city, count] = row as [string, string, number];
673
+ return {
674
+ country,
675
+ city,
676
+ count: typeof count === "number" ? count : Number(count) || 0,
677
+ };
678
+ })
679
+ .toSorted((a, b) => b.count - a.count);
680
+ }, [dashboardData.deviceGeoData?.geoData?.rows]);
681
+
682
+ const deviceTypeFilterOptions = useMemo(
683
+ () => (dashboardData.deviceGeoData?.deviceTypes?.data || []).map((d: { id: string }) => d.id).filter(Boolean),
684
+ [dashboardData.deviceGeoData?.deviceTypes?.data],
685
+ );
686
+
687
+ const countryFilterOptions = useMemo(
688
+ () => aggregatedCountries.map(([country]) => country),
689
+ [aggregatedCountries],
690
+ );
691
+
692
+ const sourceFilterOptions = useMemo(
693
+ () => (dashboardData.topSourcesData || []).map((s: any) => s.name),
694
+ [dashboardData.topSourcesData],
695
+ );
696
+
697
+ const pageUrlFilterOptions = useMemo(
698
+ () => {
699
+ const topPages = dashboardData.topPagesData;
700
+ if (!topPages?.data || topPages.data.length === 0) return [];
701
+ const indexBy = topPages.indexBy || "page";
702
+ return topPages.data
703
+ .map((row) => String((row as Record<string, unknown>)[indexBy] ?? ""))
704
+ .filter(Boolean);
705
+ },
706
+ [dashboardData.topPagesData],
707
+ );
708
+
709
+ const cityFilterOptions = useMemo(() => {
710
+ const geoRows = dashboardData.deviceGeoData?.geoData?.rows;
711
+ if (!geoRows || geoRows.length === 0) return [];
712
+ const citySet = new Set<string>();
713
+ geoRows.forEach((row) => {
714
+ const city = (row as [string, string, number])[1];
715
+ if (city) citySet.add(city);
716
+ });
717
+ return Array.from(citySet).toSorted();
718
+ }, [dashboardData.deviceGeoData?.geoData?.rows]);
719
+
720
+ const regionFilterOptions = useMemo(
721
+ () => (dashboardData.regions || []).map((r: { id: string }) => r.id),
722
+ [dashboardData.regions],
723
+ );
724
+
725
+ const eventNameFilterOptions = useMemo(() => {
726
+ const rows = dashboardData.eventTypesData?.rows;
727
+ if (!rows || rows.length === 0) return [];
728
+ return rows.map((row) => String(row[0])).filter(Boolean);
729
+ }, [dashboardData.eventTypesData?.rows]);
730
+
731
+ const { controls: toolbarControls, footer: toolbarFooter, modal: toolbarModal } =
732
+ useDashboardToolbarControls({
733
+ filters,
734
+ setFilters,
735
+ timezone: effectiveTimezone,
736
+ onNotify: notify,
737
+ isUpdating: isFetching && !isLoading,
738
+ deviceTypeOptions: deviceTypeFilterOptions,
739
+ countryOptions: countryFilterOptions,
740
+ cityOptions: cityFilterOptions,
741
+ regionOptions: regionFilterOptions,
742
+ sourceOptions: sourceFilterOptions,
743
+ pageUrlOptions: pageUrlFilterOptions,
744
+ eventNameOptions: eventNameFilterOptions,
745
+ });
746
+
747
+ if (!effectiveSiteId) {
748
+ return (
749
+ <div className="flex flex-col min-h-screen">
750
+ <main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-y-auto">
751
+ <div className="flex items-center justify-center py-12">
752
+ <span className="text-(--theme-text-secondary)">
753
+ Select a site to view the dashboard.
754
+ </span>
755
+ </div>
756
+ </main>
757
+ </div>
758
+ );
759
+ }
760
+
761
+
762
+ if (queryError) {
763
+ const message = getFriendlyDashboardErrorMessage(queryError);
764
+ const requestId =
765
+ queryError instanceof Error
766
+ ? getRequestIdFromErrorMessage(queryError.message)
767
+ : null;
768
+
769
+ return (
770
+ <div className="flex flex-col min-h-screen">
771
+ <main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-y-auto">
772
+ <div className="flex items-center justify-center py-12">
773
+ <div className="max-w-md text-center">
774
+ <p className="text-(--theme-text-primary) font-semibold mb-2">
775
+ Unable to load dashboard
776
+ </p>
777
+ <p className="text-(--theme-text-secondary) mb-4">
778
+ {message}
779
+ </p>
780
+ {requestId && (
781
+ <p className="text-xs text-(--theme-text-secondary) mb-4">
782
+ Request ID: <span className="font-mono">{requestId}</span>
783
+ </p>
784
+ )}
785
+ <div className="flex items-center justify-center gap-3">
786
+ <button
787
+ onClick={() => refetchData()}
788
+ className="bg-(--theme-bg-secondary) hover:bg-(--theme-bg-tertiary) text-(--theme-text-primary) font-medium py-2 px-4 rounded-md border border-(--theme-border-primary) transition-colors"
789
+ >
790
+ Try again
791
+ </button>
792
+ <a
793
+ href="/dashboard/settings"
794
+ className="bg-(--theme-button-bg) hover:bg-(--theme-button-hover) text-white font-medium py-2 px-4 rounded-md transition-colors"
795
+ >
796
+ Check Settings
797
+ </a>
798
+ </div>
799
+ </div>
800
+ </div>
801
+ </main>
802
+ </div>
803
+ );
804
+ }
805
+
806
+ if (!isClientReady && !initialDashboardData) {
807
+ return (
808
+ <div className="flex flex-col min-h-screen">
809
+ <main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-y-auto">
810
+ <div className="flex items-center justify-center py-12">
811
+ <span className="text-(--theme-text-secondary)">
812
+ Preparing dashboard...
813
+ </span>
814
+ </div>
815
+ </main>
816
+ </div>
817
+ );
818
+ }
819
+
820
+ if (!apiData && !isLoading) {
821
+ return (
822
+ <div className="flex flex-col min-h-screen">
823
+ <main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-y-auto">
824
+ <div className="flex items-center justify-center py-12">
825
+ <div className="max-w-md text-center">
826
+ <p className="text-(--theme-text-primary) font-semibold mb-2">
827
+ No dashboard data available
828
+ </p>
829
+ <p className="text-(--theme-text-secondary) mb-4">
830
+ We couldn’t find any metrics to display for this site yet.
831
+ </p>
832
+ <a
833
+ href="/dashboard/settings"
834
+ className="bg-(--theme-button-bg) hover:bg-(--theme-button-hover) text-white font-medium py-2 px-4 rounded-md transition-colors inline-flex focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
835
+ >
836
+ Go to Settings
837
+ </a>
838
+ </div>
839
+ </div>
840
+ </main>
841
+ </div>
842
+ );
843
+ }
844
+
845
+ return (
846
+ <div className="flex flex-col min-h-screen">
847
+ {/* Top Navigation Bar */}
848
+
849
+ <Suspense fallback={<></>}>
850
+ {/* Main Content Area */}
851
+ <main className="flex-1">
852
+ <DashboardToolbar
853
+ activeReportBuilderItemId={activeReportBuilderItemId}
854
+ reportBuilderEnabled={reportBuilderEnabled}
855
+ askAiEnabled={askAiEnabled}
856
+ controls={toolbarControls}
857
+ footer={toolbarFooter}
858
+ initialSites={initialToolbarSites}
859
+ initialSiteId={initialToolbarSiteId}
860
+ />
861
+ <div className="p-4 sm:p-6 lg:p-8">
862
+ {!apiData ? (
863
+ <>
864
+ <section className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-8">
865
+ {Array.from({ length: 6 }).map((_, index) => (
866
+ <ScorecardSkeleton key={index} />
867
+ ))}
868
+ </section>
869
+
870
+ <section className="mb-8">
871
+ <h2 className="text-2xl font-bold mb-6 text-(--theme-text-primary)">
872
+ Key Metrics Visualized
873
+ </h2>
874
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
875
+ <div className="lg:col-span-2 bg-(--theme-card-bg) border border-(--theme-card-border) rounded-lg p-6">
876
+ <SkeletonBlock className="h-5 w-40 mb-4" />
877
+ <ChartSkeleton height="350px" />
878
+ </div>
879
+ <div className="bg-(--theme-card-bg) border border-(--theme-card-border) rounded-lg p-6">
880
+ <SkeletonBlock className="h-5 w-32 mb-4" />
881
+ <ChartSkeleton height="350px" />
882
+ </div>
883
+ <div className="bg-(--theme-card-bg) border border-(--theme-card-border) rounded-lg p-6">
884
+ <SkeletonBlock className="h-5 w-32 mb-4" />
885
+ <ChartSkeleton height="350px" />
886
+ </div>
887
+ </div>
888
+ </section>
889
+
890
+ <section className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-8">
891
+ {Array.from({ length: 4 }).map((_, index) => (
892
+ <div
893
+ key={index}
894
+ className="bg-(--theme-card-bg) border border-(--theme-card-border) rounded-lg p-6"
895
+ >
896
+ <SkeletonBlock className="h-5 w-40 mb-4" />
897
+ <div className="space-y-3">
898
+ {Array.from({ length: 5 }).map((__, rowIndex) => (
899
+ <SkeletonBlock key={rowIndex} className="h-4 w-full" />
900
+ ))}
901
+ </div>
902
+ </div>
903
+ ))}
904
+ </section>
905
+ </>
906
+ ) : (apiData && apiData.noSiteRecordsExist) ? (
907
+ <div className="flex flex-col items-center justify-center gap-6 w-full">
908
+ <div className="text-center w-full max-w-4xl px-4">
909
+ <h2 className="text-2xl font-bold mb-2 text-(--theme-text-primary)">
910
+ No data yet
911
+ </h2>
912
+ <p className="text-(--theme-text-secondary) mb-2">
913
+ We haven’t collected any events for this site.
914
+ </p>
915
+ <p className="text-xs text-(--theme-text-secondary) mb-6">
916
+ Add the Lytx site tag, then refresh this page once traffic starts flowing.
917
+ </p>
918
+ <div className="flex flex-wrap items-center justify-center gap-3">
919
+ <a
920
+ href="/dashboard/settings"
921
+ className="inline-flex items-center px-4 py-2 bg-(--theme-input-bg) text-(--theme-text-primary) border border-(--theme-input-border) rounded hover:bg-(--theme-bg-secondary) transition-colors focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
922
+ >
923
+ Open settings
924
+ </a>
925
+ </div>
926
+ </div>
927
+ {currentSiteTag ? (
928
+ <div id="site-tag-install" className="w-full max-w-5xl mx-auto">
929
+ <SiteTagInstallCard site={currentSiteTag} />
930
+ </div>
931
+ ) : null}
932
+ </div>
933
+ ) : (apiData && apiData.Pagination?.total === 0) ? (
934
+ <div className="flex flex-col items-center justify-center gap-4 w-full">
935
+ <div className="text-center w-full max-w-4xl px-4">
936
+ <h2 className="text-2xl font-bold mb-2 text-(--theme-text-primary)">
937
+ No data for this date range
938
+ </h2>
939
+ <p className="text-(--theme-text-secondary) mb-2">
940
+ Try expanding the date filter to see activity.
941
+ </p>
942
+ </div>
943
+ </div>
944
+ ) : (
945
+ <>
946
+ {/* KPI Metrics Row */}
947
+ <div className="relative mb-8">
948
+ <section className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
949
+ {apiData
950
+ ? apiData.ScoreCards.map((scoreCard) => (
951
+ <Scorecard
952
+ key={scoreCard.title}
953
+ title={scoreCard.title}
954
+ value={scoreCard.value.toString()}
955
+ change={scoreCard.change.toString()}
956
+ changeType={scoreCard.changeType}
957
+ changeLabel={scoreCard.changeLabel}
958
+ />
959
+ ))
960
+ : ""}
961
+ </section>
962
+ </div>
963
+
964
+
965
+
966
+ {/* Main Visualization Area */}
967
+ {/* This section itself is already styled as a card: bg-white p-4 shadow rounded-lg */}
968
+ {/* So, ChartComponents can be direct children or wrapped in a grid for layout */}
969
+ <section className="mb-8">
970
+ <h2 className="text-2xl font-bold mb-6 text-(--theme-text-primary)">
971
+ Key Metrics Visualized
972
+ </h2>
973
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
974
+ {/* Page Views Chart - takes full width on small, half on large */}
975
+ <div className="lg:col-span-2">
976
+ <ChartComponent
977
+ chartId="pageViewsChart"
978
+ chartData={dashboardData.pageViewsData}
979
+ isLoading={isFetching}
980
+ type="line"
981
+ title={dashboardChartTitles.pageViews}
982
+ />
983
+ </div>
984
+ {/* Referrers Chart */}
985
+ <ChartComponent
986
+ chartId="referrersChart"
987
+ chartData={dashboardData.referrersData}
988
+ title={dashboardChartTitles.topReferrers}
989
+ type="pie"
990
+ isLoading={isFetching}
991
+ onItemClick={(id) => setFilters((prev) => ({ ...prev, source: id }))}
992
+ />
993
+ {/* Device Types Chart - part of deviceGeoData */}
994
+ {dashboardData.deviceGeoData && (
995
+ <ChartComponent
996
+ chartId="deviceTypesChart"
997
+ chartData={dashboardData.deviceGeoData.deviceTypes}
998
+ title={dashboardChartTitles.deviceTypes}
999
+ type="pie"
1000
+ isLoading={isFetching}
1001
+ onItemClick={(id) => setFilters((prev) => ({ ...prev, deviceType: id }))}
1002
+ />
1003
+ )}
1004
+ </div>
1005
+ </section>
1006
+
1007
+ {/* Detailed Information Grid (2x2) */}
1008
+ <section className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
1009
+ {/* Top Sources Card (Top-Left) */}
1010
+ <DashboardCard title={dashboardChartTitles.topSources} titleAs="h3">
1011
+ <CardTabs
1012
+ tabs={["Sources", "Referrers"]}
1013
+ activeTab={topSourcesTab}
1014
+ onTabClick={(tab) =>
1015
+ setTopSourcesTab(tab as "Sources" | "Referrers")
1016
+ }
1017
+ >
1018
+ <ul className="space-y-3">
1019
+ {topSourcesTab === "Sources"
1020
+ ? (dashboardData.topSourcesData || []).map((source: any, index: number) => (
1021
+ <li
1022
+ key={source.name}
1023
+ className="flex items-center justify-between py-2 border-b border-(--theme-border-primary) last:border-b-0 cursor-pointer hover:bg-(--theme-bg-secondary) rounded px-1 -mx-1 transition-colors"
1024
+ onClick={() => setFilters((prev) => ({ ...prev, source: source.name }))}
1025
+ >
1026
+ <div className="flex items-center">
1027
+ <span
1028
+ className="w-4 h-4 rounded-full mr-3"
1029
+ style={{ backgroundColor: chartColors.mixed[index % chartColors.mixed.length] }}
1030
+ />
1031
+ <span className="text-sm text-(--theme-text-primary)">
1032
+ {source.name}
1033
+ </span>
1034
+ </div>
1035
+ <span className="text-sm text-(--theme-text-primary) font-medium">
1036
+ {source.visitors.toLocaleString()}
1037
+ </span>
1038
+ </li>
1039
+ ))
1040
+ : (dashboardData.referrersData?.data || []).map(
1041
+ (referrer: any, index: number) => (
1042
+ <li
1043
+ key={referrer.id}
1044
+ className="flex items-center justify-between py-2 border-b border-(--theme-border-primary) last:border-b-0 cursor-pointer hover:bg-(--theme-bg-secondary) rounded px-1 -mx-1 transition-colors"
1045
+ onClick={() => setFilters((prev) => ({ ...prev, source: referrer.id }))}
1046
+ >
1047
+ <div className="flex items-center">
1048
+ <span
1049
+ className="w-4 h-4 rounded-full mr-3"
1050
+ style={{ backgroundColor: chartColors.mixed[index % chartColors.mixed.length] }}
1051
+ />
1052
+ <span className="text-sm text-(--theme-text-primary)">
1053
+ {referrer.id}
1054
+ </span>
1055
+ </div>
1056
+ <span className="text-sm text-(--theme-text-primary) font-medium">
1057
+ {referrer.value.toLocaleString()}
1058
+ </span>
1059
+ </li>
1060
+ ),
1061
+ )}
1062
+ </ul>
1063
+ </CardTabs>
1064
+ </DashboardCard>
1065
+
1066
+ {/* Top Pages Card (Top-Right) */}
1067
+ <DashboardCard title={dashboardChartTitles.topPages} titleAs="h3">
1068
+ <div style={{ height: "250px", cursor: "pointer" }}>
1069
+ <ResponsiveBar
1070
+ data={dashboardData.topPagesData?.data || []}
1071
+ keys={dashboardData.topPagesData?.keys || []}
1072
+ indexBy={dashboardData.topPagesData?.indexBy || "page"}
1073
+ layout="horizontal"
1074
+ margin={
1075
+ isSmallScreen
1076
+ ? { top: 10, right: 10, bottom: 20, left: 80 }
1077
+ : { top: 10, right: 10, bottom: 20, left: 120 }
1078
+ }
1079
+ padding={0.3}
1080
+ colors={["#3B82F6"]}
1081
+ borderColor="#1D4ED8"
1082
+ borderWidth={2}
1083
+ axisTop={null}
1084
+ axisRight={null}
1085
+ axisBottom={null}
1086
+ axisLeft={{
1087
+ tickSize: 0,
1088
+ tickPadding: 5,
1089
+ tickRotation: 0,
1090
+ legend: "",
1091
+ format: (value) => {
1092
+ const maxLen = isSmallScreen ? 16 : 28;
1093
+ return truncateAxisLabel(value, maxLen);
1094
+ },
1095
+ }}
1096
+ enableGridX={true}
1097
+ enableGridY={false}
1098
+ gridXValues={5}
1099
+ enableLabel={false}
1100
+ isInteractive={true}
1101
+ onClick={(bar) => setFilters((prev) => ({ ...prev, pageUrl: String(bar.indexValue) }))}
1102
+ tooltip={({ indexValue, value, color }) => (
1103
+ <div
1104
+ style={{
1105
+ padding: "6px 10px",
1106
+ background: "#1F2937",
1107
+ color: "#F9FAFB",
1108
+ border: `1px solid ${color}`,
1109
+ borderRadius: "3px",
1110
+ fontSize: "12px",
1111
+ fontWeight: 600,
1112
+ }}
1113
+ >
1114
+ <strong>{String(indexValue)}</strong>: {value.toLocaleString()} views
1115
+ </div>
1116
+ )}
1117
+ theme={{
1118
+ axis: {
1119
+ ticks: {
1120
+ text: {
1121
+ fill: "var(--theme-text-secondary)",
1122
+ fontSize: isSmallScreen ? 10 : 11,
1123
+ fontWeight: 600,
1124
+ },
1125
+ },
1126
+ },
1127
+ grid: {
1128
+ line: {
1129
+ stroke: "var(--theme-border-primary)",
1130
+ strokeDasharray: "2 2",
1131
+ },
1132
+ },
1133
+ }}
1134
+ />
1135
+ </div>
1136
+ </DashboardCard>
1137
+
1138
+ {/* Locations Card (Bottom-Left) */}
1139
+ <DashboardCard title={dashboardChartTitles.locations} titleAs="h3">
1140
+ <CardTabs
1141
+ tabs={["Countries", "Cities"]}
1142
+ activeTab={locationsTab}
1143
+ onTabClick={(tab) =>
1144
+ setLocationsTab(tab as "Countries" | "Cities")
1145
+ }
1146
+ >
1147
+ <div style={{ height: "360px" }}>
1148
+ {locationsTab === "Countries" ? (
1149
+ aggregatedCountries.length === 0 ? (
1150
+ <div className="flex flex-col items-center justify-center h-full text-center">
1151
+ <p className="text-(--theme-text-secondary)">
1152
+ No location data available
1153
+ </p>
1154
+ <p className="text-xs text-(--theme-text-secondary) mt-2">
1155
+ Try a longer date range or clear filters.
1156
+ </p>
1157
+ </div>
1158
+ ) : (
1159
+ <ul
1160
+ className="overflow-y-auto overflow-x-hidden scrollbar-none"
1161
+ style={{ maxHeight: `${GEO_LIST_MAX_HEIGHT}px` }}
1162
+ >
1163
+ {aggregatedCountries.map(([country, count]) => (
1164
+ <li
1165
+ key={country}
1166
+ className="flex min-h-9 items-center justify-between border-b border-(--theme-border-primary) px-1 py-1.5 last:border-b-0 cursor-pointer hover:bg-(--theme-bg-secondary) rounded transition-colors"
1167
+ onClick={() => setFilters((prev) => ({ ...prev, country }))}
1168
+ >
1169
+ <div className="flex items-center">
1170
+ <span className="mr-3 inline-flex h-4 w-6 items-center justify-center">
1171
+ {getCountryFlagIcon(country) ?? (
1172
+ <span className="h-4 w-4 rounded-full bg-blue-500" />
1173
+ )}
1174
+ </span>
1175
+ <span className="text-sm text-(--theme-text-primary)">
1176
+ {country}
1177
+ </span>
1178
+ </div>
1179
+ <span className="text-sm text-(--theme-text-primary) font-medium">
1180
+ {count.toLocaleString()}
1181
+ </span>
1182
+ </li>
1183
+ ))}
1184
+ </ul>
1185
+ )
1186
+ ) : rankedGeoCities.length === 0 ? (
1187
+ <div className="flex flex-col items-center justify-center h-full text-center">
1188
+ <p className="text-(--theme-text-secondary)">
1189
+ No cities data available
1190
+ </p>
1191
+ <p className="text-xs text-(--theme-text-secondary) mt-2">
1192
+ Try a longer date range or clear filters.
1193
+ </p>
1194
+ </div>
1195
+ ) : (
1196
+ <ul
1197
+ className="overflow-y-auto overflow-x-hidden scrollbar-none"
1198
+ style={{ maxHeight: `${GEO_LIST_MAX_HEIGHT}px` }}
1199
+ >
1200
+ {rankedGeoCities.map(({ country, city, count }) => {
1201
+ return (
1202
+ <li
1203
+ key={`${city}-${country}`}
1204
+ className="flex min-h-9 items-center justify-between border-b border-(--theme-border-primary) px-1 py-1.5 last:border-b-0 cursor-pointer hover:bg-(--theme-bg-secondary) rounded transition-colors"
1205
+ onClick={() => setFilters((prev) => ({ ...prev, city, country }))}
1206
+ >
1207
+ <div className="flex items-center">
1208
+ <span className="mr-3 inline-flex h-4 w-6 items-center justify-center">
1209
+ {getCountryFlagIcon(country) ?? (
1210
+ <span className="h-4 w-4 rounded-full bg-blue-500" />
1211
+ )}
1212
+ </span>
1213
+ <span className="text-sm text-(--theme-text-primary)">
1214
+ {city}, <span className="text-(--theme-text-secondary)">{country}</span>
1215
+ </span>
1216
+ </div>
1217
+ <span className="text-sm text-(--theme-text-primary) font-medium">
1218
+ {count.toLocaleString()}
1219
+ </span>
1220
+ </li>
1221
+ );
1222
+ })}
1223
+ </ul>
1224
+ )}
1225
+ </div>
1226
+ </CardTabs>
1227
+ </DashboardCard>
1228
+
1229
+ {/* Devices Card (Bottom-Right) */}
1230
+ <DashboardCard title={dashboardChartTitles.devices} titleAs="h3">
1231
+ <CardTabs
1232
+ tabs={["Browser", "OS"]}
1233
+ activeTab={devicesTab}
1234
+ onTabClick={(tab) => setDevicesTab(tab as "Browser" | "OS")}
1235
+ >
1236
+ <ul className="space-y-3 pt-4">
1237
+ {(devicesTab === "Browser"
1238
+ ? dashboardData.browserData || []
1239
+ : dashboardData.osData || []
1240
+ ).map((device: any) => (
1241
+ <li
1242
+ key={device.name}
1243
+ className="flex items-center justify-between py-2 border-b border-(--theme-border-primary) last:border-b-0"
1244
+ >
1245
+ <div className="flex items-center">
1246
+ <span className="mr-3 inline-flex h-5 w-5 items-center justify-center">
1247
+ {devicesTab === "Browser"
1248
+ ? getBrowserLogo(device.name) ?? getBrowserIcon(device.name) ?? (
1249
+ <span className="h-4 w-4 rounded-full bg-sky-500" />
1250
+ )
1251
+ : (
1252
+ getOsLogo(device.name) ??
1253
+ getOsIcon(device.name) ?? (
1254
+ <span className={`h-4 w-4 rounded-full ${getOsDotClass(device.name)}`} />
1255
+ )
1256
+ )}
1257
+ </span>
1258
+ <span className="text-sm text-(--theme-text-primary)">
1259
+ {device.name}
1260
+ </span>
1261
+ </div>
1262
+ <div className="flex items-center space-x-2">
1263
+ <span className="text-sm text-(--theme-text-primary) font-medium">
1264
+ {device.visitors.toLocaleString()}
1265
+ </span>
1266
+ {device.percentage && (
1267
+ <span className="text-xs text-(--theme-text-secondary)">
1268
+ ({device.percentage})
1269
+ </span>
1270
+ )}
1271
+ </div>
1272
+ </li>
1273
+ ))}
1274
+ </ul>
1275
+ </CardTabs>
1276
+ </DashboardCard>
1277
+ </section>
1278
+
1279
+ {/* Visitor Map */}
1280
+ <section className="mb-8">
1281
+ <WorldMapCard
1282
+ aggregatedCountries={mapCountries}
1283
+ isDark={theme === "dark"}
1284
+ metricLabel="visitors"
1285
+ />
1286
+ </section>
1287
+
1288
+ {/* Events Summary */}
1289
+ <section className="mb-8">
1290
+ <EventSummaryTable
1291
+ data={dashboardData.eventSummary}
1292
+ isLoading={isFetching}
1293
+ timezone={effectiveTimezone}
1294
+ labelsMap={eventLabelsMap}
1295
+ />
1296
+ </section>
1297
+
1298
+ {/* Restored Detailed Data Section */}
1299
+ <section className="relative mb-8">
1300
+
1301
+
1302
+ {(() => {
1303
+ const eventTypesTableData = dashboardData.eventTypesData;
1304
+ if (eventTypesTableData) {
1305
+ return (
1306
+ <EventTypesFunnel
1307
+ tableId="eventTypesTable"
1308
+ tableData={eventTypesTableData}
1309
+ labelsMap={eventLabelsMap}
1310
+ />
1311
+ );
1312
+ }
1313
+ return null;
1314
+ })()}
1315
+
1316
+
1317
+ </section>
1318
+
1319
+ </>)}
1320
+ </div>
1321
+ {notice && (
1322
+ <div className="fixed bottom-4 right-4 z-60 w-[min(24rem,calc(100vw-2rem))]">
1323
+ <AlertBanner
1324
+ tone={notice.type}
1325
+ message={notice.message}
1326
+ onDismiss={() => setNotice(null)}
1327
+ />
1328
+ </div>
1329
+ )}
1330
+ </main>
1331
+ </Suspense>
1332
+
1333
+ {toolbarModal}
1334
+
1335
+ </div>
1336
+ );
1337
+ }
1338
+
1339
+ export default DashboardPage;