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,1481 @@
1
+ "use client";
2
+
3
+ import { DashboardCard } from "@components/DashboardCard";
4
+ import { useTheme } from "@/app/providers/ThemeProvider";
5
+
6
+ import getUnicodeFlag from "country-flag-icons/unicode";
7
+ import { createChartTheme, chartColors } from "@/app/utils/chartThemes";
8
+ import { useMediaQuery } from "@/app/utils/media";
9
+ import {
10
+ ScorecardProps,
11
+ type ChartComponentProps,
12
+ type NivoBarChartData,
13
+ type NivoLineChartData,
14
+ type NivoPieChartData,
15
+ type TableComponentProps,
16
+ } from "@db/tranformReports";
17
+ import { ResponsiveBar } from "@nivo/bar";
18
+ import { ResponsiveLine } from "@nivo/line";
19
+ import { ResponsivePie } from "@nivo/pie";
20
+ import { useEffect, useId, useRef, useState } from "react";
21
+ import { useQuery } from "@tanstack/react-query";
22
+ import { useKeybinds } from "@/app/utils/keybinds";
23
+
24
+ type DateParts = { year: number; month: number; day: number };
25
+
26
+ export type DashboardNoticeType = "success" | "error" | "info";
27
+
28
+ export type DashboardNotice = {
29
+ type: DashboardNoticeType;
30
+ message: string;
31
+ };
32
+
33
+ // Filter interfaces
34
+ export interface DateRange {
35
+ start: string;
36
+ end: string;
37
+ preset?: string;
38
+ }
39
+ export interface DashboardFilters {
40
+ dateRange: DateRange;
41
+ deviceType?: string;
42
+ country?: string;
43
+ city?: string;
44
+ region?: string;
45
+ source?: string;
46
+ pageUrl?: string;
47
+ eventName?: string;
48
+ siteId?: string;
49
+ }
50
+ export type CurrentVisitorsResponse = {
51
+ currentVisitors: number;
52
+ windowSeconds: number;
53
+ timestamp: string;
54
+ siteId: number | null;
55
+ error?: string;
56
+ };
57
+
58
+ export const isValidTimeZone = (value: unknown): value is string => {
59
+ if (typeof value !== "string" || value.trim().length === 0) return false;
60
+ try {
61
+ Intl.DateTimeFormat(undefined, { timeZone: value.trim() });
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
67
+
68
+ export const getBrowserTimeZone = (): string => {
69
+ const guessed = Intl.DateTimeFormat().resolvedOptions().timeZone;
70
+ return isValidTimeZone(guessed) ? guessed : "UTC";
71
+ };
72
+
73
+ export const formatDateParts = ({ year, month, day }: DateParts): string => {
74
+ const mm = String(month).padStart(2, "0");
75
+ const dd = String(day).padStart(2, "0");
76
+ return `${year}-${mm}-${dd}`;
77
+ };
78
+
79
+ export const getDatePartsInTimeZone = (date: Date, timeZone: string): DateParts => {
80
+ const formatter = new Intl.DateTimeFormat("en-CA", {
81
+ timeZone,
82
+ year: "numeric",
83
+ month: "2-digit",
84
+ day: "2-digit",
85
+ });
86
+
87
+ const parts = formatter.formatToParts(date);
88
+ const year = Number(parts.find((part) => part.type === "year")?.value);
89
+ const month = Number(parts.find((part) => part.type === "month")?.value);
90
+ const day = Number(parts.find((part) => part.type === "day")?.value);
91
+
92
+ return {
93
+ year: Number.isFinite(year) ? year : date.getUTCFullYear(),
94
+ month: Number.isFinite(month) ? month : date.getUTCMonth() + 1,
95
+ day: Number.isFinite(day) ? day : date.getUTCDate(),
96
+ };
97
+ };
98
+
99
+ export const getDateStringInTimeZone = (date: Date, timeZone: string): string => {
100
+ return formatDateParts(getDatePartsInTimeZone(date, timeZone));
101
+ };
102
+
103
+ export const shiftDateString = (dateString: string, days: number): string => {
104
+ const [year, month, day] = dateString.split("-").map((value) => Number(value));
105
+ const shifted = new Date(Date.UTC(year, month - 1, day));
106
+ shifted.setUTCDate(shifted.getUTCDate() + days);
107
+ return formatDateParts({
108
+ year: shifted.getUTCFullYear(),
109
+ month: shifted.getUTCMonth() + 1,
110
+ day: shifted.getUTCDate(),
111
+ });
112
+ };
113
+
114
+
115
+ export const getCountryFlagIcon = (country: string | null | undefined) => {
116
+ const code = typeof country === "string" ? country.trim().toUpperCase() : "";
117
+
118
+ if (!/^[A-Z]{2}$/.test(code)) {
119
+ return null;
120
+ }
121
+
122
+ try {
123
+ const flag = getUnicodeFlag(code);
124
+ return (
125
+ <span
126
+ role="img"
127
+ aria-label={code}
128
+ className="text-base leading-none"
129
+ >
130
+ {flag}
131
+ </span>
132
+ );
133
+ } catch {
134
+ return null;
135
+ }
136
+ };
137
+
138
+ export const CardTabs: React.FC<{
139
+ tabs: string[];
140
+ activeTab: string;
141
+ onTabClick: (tab: string) => void;
142
+ ariaLabel?: string;
143
+ children?: React.ReactNode;
144
+ }> = ({ tabs, activeTab, onTabClick, ariaLabel = "Dashboard card tabs", children }) => {
145
+ const baseId = useId();
146
+ const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
147
+
148
+ const getTabId = (tab: string) => `${baseId}-tab-${tab}`;
149
+ const getPanelId = (tab: string) => `${baseId}-panel-${tab}`;
150
+
151
+ const focusAndSelect = (index: number) => {
152
+ const tab = tabs[index];
153
+ if (!tab) return;
154
+ onTabClick(tab);
155
+ tabRefs.current[index]?.focus();
156
+ };
157
+
158
+ const handleKeyDown = (
159
+ event: React.KeyboardEvent<HTMLButtonElement>,
160
+ index: number,
161
+ ) => {
162
+ if (event.key === "ArrowRight") {
163
+ event.preventDefault();
164
+ focusAndSelect((index + 1) % tabs.length);
165
+ return;
166
+ }
167
+
168
+ if (event.key === "ArrowLeft") {
169
+ event.preventDefault();
170
+ focusAndSelect((index - 1 + tabs.length) % tabs.length);
171
+ return;
172
+ }
173
+
174
+ if (event.key === "Home") {
175
+ event.preventDefault();
176
+ focusAndSelect(0);
177
+ return;
178
+ }
179
+
180
+ if (event.key === "End") {
181
+ event.preventDefault();
182
+ focusAndSelect(tabs.length - 1);
183
+ }
184
+ };
185
+
186
+ return (
187
+ <>
188
+ <div
189
+ role="tablist"
190
+ aria-label={ariaLabel}
191
+ className="flex border-b border-(--theme-border-primary) mb-4"
192
+ >
193
+ {tabs.map((tab: string, index) => {
194
+ const isActive = activeTab === tab;
195
+ return (
196
+ <button
197
+ key={tab}
198
+ id={getTabId(tab)}
199
+ ref={(element) => {
200
+ tabRefs.current[index] = element;
201
+ }}
202
+ type="button"
203
+ role="tab"
204
+ aria-selected={isActive}
205
+ aria-controls={getPanelId(tab)}
206
+ tabIndex={isActive ? 0 : -1}
207
+ onClick={() => onTabClick(tab)}
208
+ onKeyDown={(event) => handleKeyDown(event, index)}
209
+ className={`py-2 px-4 font-semibold focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary) rounded-t ${isActive
210
+ ? "text-(--theme-text-primary) border-b-2 border-(--theme-border-primary)"
211
+ : "text-(--theme-text-secondary) hover:text-(--theme-text-primary)"
212
+ }`}
213
+ >
214
+ {tab}
215
+ </button>
216
+ );
217
+ })}
218
+ </div>
219
+ {children && (
220
+ <div
221
+ role="tabpanel"
222
+ id={getPanelId(activeTab)}
223
+ aria-labelledby={getTabId(activeTab)}
224
+ >
225
+ {children}
226
+ </div>
227
+ )}
228
+ </>
229
+ );
230
+ };
231
+
232
+ export const TableComponent: React.FC<TableComponentProps> = ({
233
+ tableId,
234
+ tableData,
235
+ title,
236
+ }) => {
237
+ const displayTitle = title || tableData?.title || "Table";
238
+ const isGeoTable = tableId === "geoDataTable";
239
+ const isEmpty = !tableData || !tableData.headers || !tableData.rows || tableData.rows.length === 0;
240
+
241
+ return (
242
+ <DashboardCard id={tableId} title={displayTitle} className="mb-6" empty={isEmpty}>
243
+ <div className="relative w-full">
244
+ <div className="overflow-x-auto scrollbar-none">
245
+ <table className="min-w-160 w-full divide-y divide-(--theme-border-primary)">
246
+ <thead className="bg-(--theme-bg-secondary)">
247
+ <tr>
248
+ {(tableData?.headers || []).map((header) => (
249
+ <th
250
+ key={header}
251
+ scope="col"
252
+ className="px-3 sm:px-6 py-3 text-left text-xs font-medium text-(--theme-text-secondary) uppercase tracking-wider"
253
+ >
254
+ {header}
255
+ </th>
256
+ ))}
257
+ </tr>
258
+ </thead>
259
+ <tbody className="bg-(--theme-card-bg) divide-y divide-(--theme-border-primary)">
260
+ {(tableData?.rows || []).map((row, rowIndex) => (
261
+ <tr
262
+ key={rowIndex}
263
+ className="hover:bg-(--theme-bg-secondary) transition-colors"
264
+ >
265
+ {(row || []).map((cell, cellIndex) => (
266
+ <td
267
+ key={cellIndex}
268
+ className="px-3 sm:px-6 py-4 text-sm text-(--theme-text-primary)"
269
+ >
270
+ {isGeoTable && cellIndex === 0 && typeof cell === "string" ? (
271
+ <div className="flex items-center gap-2">
272
+ <span className="inline-flex h-4 w-6 items-center justify-center">
273
+ {getCountryFlagIcon(cell) ?? (
274
+ <span className="h-4 w-4 rounded-full bg-blue-500" />
275
+ )}
276
+ </span>
277
+ <span>{cell}</span>
278
+ </div>
279
+ ) : (
280
+ cell
281
+ )}
282
+ </td>
283
+ ))}
284
+ </tr>
285
+ ))}
286
+ </tbody>
287
+ </table>
288
+ </div>
289
+ <div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-linear-to-l from-(--theme-card-bg) to-transparent sm:hidden" />
290
+ </div>
291
+ </DashboardCard>
292
+ );
293
+ };
294
+
295
+ export const ChartSkeleton = ({ height = "350px" }: { height?: string | number }) => (
296
+ <div
297
+ style={{ height: typeof height === "number" ? `${height}px` : height }}
298
+ className="w-full rounded-md bg-(--theme-bg-secondary) animate-pulse"
299
+ />
300
+ );
301
+ export const ChartComponent: React.FC<ChartComponentProps> = (props) => {
302
+ const { chartId, chartData, title, height = "350px" } = props;
303
+ const isSmallScreen = useMediaQuery("(max-width: 640px)");
304
+ const chartContainerRef = useRef<HTMLDivElement>(null);
305
+ const { theme } = useTheme();
306
+
307
+ const [error] = useState<string | null>(null);
308
+
309
+ const chartTheme = createChartTheme(theme === "dark");
310
+
311
+
312
+ const renderChart = () => {
313
+ if (!chartData || !props.type) return null; // chartType is set in useEffect
314
+
315
+ if (
316
+ props.type === "bar" &&
317
+ "keys" in chartData &&
318
+ "indexBy" in chartData
319
+ ) {
320
+ // Type guard for bar chart data
321
+ const barData = chartData as NivoBarChartData;
322
+ return (
323
+ <ResponsiveBar
324
+ data={barData?.data || []}
325
+ keys={barData?.keys || []}
326
+ indexBy={barData?.indexBy || "date"}
327
+ margin={
328
+ isSmallScreen
329
+ ? { top: 40, right: 20, bottom: 60, left: 44 }
330
+ : { top: 50, right: 130, bottom: 50, left: 60 }
331
+ }
332
+ padding={0.3}
333
+ valueScale={{ type: "linear" }}
334
+ indexScale={{ type: "band", round: true }}
335
+ colors={chartColors.primary}
336
+ borderColor="#1D4ED8"
337
+ borderWidth={2}
338
+ enableLabel={false}
339
+ axisTop={null}
340
+ axisRight={null}
341
+ axisBottom={
342
+ barData.axisBottom || {
343
+ tickSize: 5,
344
+ tickPadding: 5,
345
+ tickRotation: 0,
346
+ legend: "index",
347
+ legendPosition: "middle",
348
+ legendOffset: 32,
349
+ }
350
+ }
351
+ axisLeft={
352
+ barData.axisLeft || {
353
+ tickSize: 5,
354
+ tickPadding: 5,
355
+ tickRotation: 0,
356
+ legend: "value",
357
+ legendPosition: "middle",
358
+ legendOffset: -40,
359
+ }
360
+ }
361
+ labelSkipWidth={12}
362
+ labelSkipHeight={12}
363
+ labelTextColor={{ from: "color", modifiers: [["darker", 1.6]] }}
364
+ legends={
365
+ isSmallScreen
366
+ ? []
367
+ : barData.legends || [
368
+ {
369
+ dataFrom: "keys",
370
+ anchor: "bottom-right",
371
+ direction: "column",
372
+ justify: false,
373
+ translateX: 120,
374
+ translateY: 0,
375
+ itemsSpacing: 2,
376
+ itemWidth: 100,
377
+ itemHeight: 20,
378
+ itemDirection: "left-to-right",
379
+ itemOpacity: 0.85,
380
+ symbolSize: 20,
381
+ effects: [{ on: "hover", style: { itemOpacity: 1 } }],
382
+ },
383
+ ]
384
+ }
385
+ animate={true}
386
+ theme={chartTheme}
387
+ />
388
+ );
389
+ } else if (props.type === "pie") {
390
+ const pieData = chartData as NivoPieChartData;
391
+ return (
392
+ <ResponsivePie
393
+ data={pieData?.data || []}
394
+ margin={
395
+ isSmallScreen
396
+ ? { top: 10, right: 10, bottom: 10, left: 10 }
397
+ : { top: 40, right: 80, bottom: 80, left: 80 }
398
+ }
399
+ onClick={(datum) => props.onItemClick?.(String(datum.id))}
400
+ innerRadius={0.5}
401
+ padAngle={0.7}
402
+ cornerRadius={3}
403
+ activeOuterRadiusOffset={8}
404
+ borderWidth={1}
405
+ borderColor={{ from: "color", modifiers: [["darker", 0.2]] }}
406
+ enableArcLinkLabels={!isSmallScreen}
407
+ arcLinkLabelsSkipAngle={10}
408
+ arcLinkLabelsThickness={2}
409
+ arcLinkLabelsColor={{ from: "color" }}
410
+ arcLinkLabelsTextColor={theme === "dark" ? "#FFFFFF" : "#111827"}
411
+ arcLabelsSkipAngle={isSmallScreen ? 30 : 10}
412
+ arcLabelsTextColor={theme === "dark" ? "#FFFFFF" : "#111827"}
413
+ colors={chartColors.mixed}
414
+ legends={
415
+ isSmallScreen
416
+ ? []
417
+ : pieData.legends || [
418
+ {
419
+ anchor: "bottom",
420
+ direction: "row",
421
+ justify: false,
422
+ translateX: 0,
423
+ translateY: 56,
424
+ itemsSpacing: 0,
425
+ itemWidth: 100,
426
+ itemHeight: 18,
427
+ itemDirection: "left-to-right",
428
+ itemOpacity: 1,
429
+ symbolSize: 18,
430
+ symbolShape: "circle",
431
+ itemTextColor: theme === "dark" ? "#ffffff" : "#374151",
432
+ effects: [{ on: "hover", style: { itemOpacity: 1 } }],
433
+ },
434
+ ]
435
+ }
436
+ theme={chartTheme}
437
+ />
438
+ );
439
+ } else if (props.type === "line") {
440
+ const lineData = chartData as NivoLineChartData;
441
+ const rawSeries = lineData?.data || [];
442
+ const isSinglePoint = rawSeries.length > 0 && rawSeries.every((s) => s.data.length === 1);
443
+ const singlePointDate = isSinglePoint ? String(rawSeries[0].data[0].x) : null;
444
+
445
+ const series = rawSeries.map((s) => {
446
+ if (s.data.length !== 1) return s;
447
+ const pt = s.data[0];
448
+ const xVal = String(pt.x);
449
+ if (/^\d{4}-\d{2}-\d{2}$/.test(xVal)) {
450
+ const d = new Date(xVal);
451
+ const prev = new Date(d);
452
+ prev.setDate(prev.getDate() - 1);
453
+ const next = new Date(d);
454
+ next.setDate(next.getDate() + 1);
455
+ const fmt = (dt: Date) => dt.toISOString().slice(0, 10);
456
+ return { ...s, data: [{ x: fmt(prev), y: 0 }, pt, { x: fmt(next), y: 0 }] };
457
+ }
458
+ return { ...s, data: [{ x: "", y: 0 }, pt, { x: " ", y: 0 }] };
459
+ });
460
+
461
+ return (
462
+ <>
463
+ <ResponsiveLine
464
+ data={series}
465
+ margin={{ top: 50, right: 20, bottom: 50, left: 50 }}
466
+ xScale={{ type: "point" }}
467
+ yScale={{
468
+ type: "linear",
469
+ min: 0,
470
+ max: "auto",
471
+ stacked: false,
472
+ reverse: false,
473
+ }}
474
+ colors={chartColors.line}
475
+ curve="monotoneX"
476
+ enableArea={true}
477
+ areaOpacity={1}
478
+ defs={[
479
+ {
480
+ id: "accentGradient",
481
+ type: "linearGradient",
482
+ colors: [
483
+ { offset: 0, color: "#3B82F6", opacity: 0.4 },
484
+ { offset: 100, color: "#3B82F6", opacity: 0 },
485
+ ],
486
+ },
487
+ ]}
488
+ fill={[{ match: "*", id: "accentGradient" }]}
489
+ axisTop={null}
490
+ axisRight={null}
491
+ axisLeft={{
492
+ tickSize: 0,
493
+ tickPadding: 8,
494
+ tickValues: 5,
495
+ }}
496
+ axisBottom={{
497
+ tickSize: 0,
498
+ tickPadding: 10,
499
+ tickRotation: 0,
500
+ legend: "",
501
+ legendOffset: 36,
502
+ legendPosition: "middle",
503
+ renderTick: (tick) => {
504
+ if (isSinglePoint && String(tick.value) !== singlePointDate) {
505
+ return <g />;
506
+ }
507
+ const label = String(tick.value);
508
+ const display = /^\d{4}-\d{2}-\d{2}$/.test(label)
509
+ ? `${label.split("-")[1]}/${label.split("-")[2]}`
510
+ : label;
511
+ return (
512
+ <g transform={`translate(${tick.x},${tick.y})`}>
513
+ <text
514
+ x={0}
515
+ y={15}
516
+ textAnchor="middle"
517
+ dominantBaseline="middle"
518
+ style={{ fill: "#9CA3AF", fontSize: "11px", fontWeight: 600 }}
519
+ >
520
+ {display}
521
+ </text>
522
+ </g>
523
+ );
524
+ },
525
+ }}
526
+ enableGridX={false}
527
+ enableGridY={true}
528
+ gridYValues={5}
529
+ theme={chartTheme}
530
+ pointSize={isSinglePoint ? 10 : 8}
531
+ pointColor={{ theme: "background" }}
532
+ pointBorderWidth={2}
533
+ pointBorderColor={{ from: "serieColor" }}
534
+ pointLabelYOffset={-12}
535
+ useMesh={true}
536
+ tooltip={({ point }) => (
537
+ <div style={{
538
+ background: theme === "dark" ? "#484743" : "#fff",
539
+ color: theme === "dark" ? "#fff" : "#111827",
540
+ padding: "6px 12px",
541
+ borderRadius: "8px",
542
+ fontSize: "13px",
543
+ fontWeight: 600,
544
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
545
+ border: `1px solid ${theme === "dark" ? "#575353" : "#E5E7EB"}`,
546
+ }}>
547
+ {point.data.yFormatted} page views
548
+ </div>
549
+ )}
550
+ />
551
+ </>
552
+ );
553
+ }
554
+ return null;
555
+ };
556
+
557
+ const showSkeleton = props.isLoading && !chartData;
558
+ const showOverlay = props.isLoading && !!chartData;
559
+ const showChart = !!chartData && !error;
560
+
561
+ return (
562
+ <DashboardCard title={title} className="mb-6" isUpdating={showOverlay} updatingLabel="Updating chart...">
563
+ <div
564
+ ref={chartContainerRef}
565
+ id={chartId}
566
+ style={{
567
+ height: typeof height === "number" ? `${height}px` : height,
568
+ minHeight: typeof height === "number" ? `${height}px` : height,
569
+ position: "relative",
570
+ cursor: props.onItemClick ? "pointer" : undefined,
571
+ }}
572
+ >
573
+ {showSkeleton && <ChartSkeleton height={height} />}
574
+
575
+ {showChart && renderChart()}
576
+
577
+ {!props.isLoading && error && (
578
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-danger bg-opacity-20 p-4 text-center rounded-lg">
579
+ <p className="text-danger font-semibold">Error!</p>
580
+ <p className="text-danger text-sm opacity-80">
581
+ {error}
582
+ </p>
583
+ </div>
584
+ )}
585
+ </div>
586
+ {isSmallScreen && props.type === "pie" && showChart && (() => {
587
+ const pieItems = (chartData as NivoPieChartData)?.data || [];
588
+ if (pieItems.length === 0) return null;
589
+ return (
590
+ <div className="flex flex-wrap justify-center gap-x-3 gap-y-1 pt-2">
591
+ {pieItems.map((item, i) => (
592
+ <div key={item.id} className="flex items-center gap-1.5">
593
+ <span
594
+ className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
595
+ style={{ backgroundColor: chartColors.mixed[i % chartColors.mixed.length] }}
596
+ />
597
+ <span className="text-xs text-(--theme-text-secondary)">
598
+ {item.id}
599
+ </span>
600
+ </div>
601
+ ))}
602
+ </div>
603
+ );
604
+ })()}
605
+ </DashboardCard>
606
+ );
607
+ };
608
+
609
+ export const SkeletonBlock = ({ className }: { className: string }) => (
610
+ <div
611
+ className={`animate-pulse rounded bg-(--theme-bg-secondary) ${className}`}
612
+ />
613
+ );
614
+
615
+ export const ScorecardSkeleton = () => (
616
+ <div className="bg-(--theme-card-bg) border border-(--theme-card-border) rounded-lg p-4 text-left">
617
+ <SkeletonBlock className="h-3 w-20 mb-3" />
618
+ <SkeletonBlock className="h-7 w-24 mb-3" />
619
+ <div className="flex items-center">
620
+ <SkeletonBlock className="h-3 w-16" />
621
+ </div>
622
+ </div>
623
+ );
624
+
625
+
626
+
627
+ export const SpinnerIcon = ({ className }: { className?: string }) => (
628
+ <svg
629
+ aria-hidden="true"
630
+ focusable="false"
631
+ className={className}
632
+ xmlns="http://www.w3.org/2000/svg"
633
+ fill="none"
634
+ viewBox="0 0 24 24"
635
+ >
636
+ <circle
637
+ cx="12"
638
+ cy="12"
639
+ r="10"
640
+ stroke="currentColor"
641
+ strokeWidth="4"
642
+ className="opacity-25"
643
+ ></circle>
644
+ <path
645
+ className="opacity-75"
646
+ fill="currentColor"
647
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
648
+ ></path>
649
+ </svg>
650
+ );
651
+ export const HelpTooltip = ({ text }: { text: string }) => {
652
+ const tooltipId = useId();
653
+ return (
654
+ <span className="relative inline-flex items-center group">
655
+ <button
656
+ type="button"
657
+ className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-(--theme-border-primary) text-[10px] text-(--theme-text-secondary) hover:text-(--theme-text-primary) hover:border-(--theme-border-primary) transition-colors focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
658
+ aria-describedby={tooltipId}
659
+ >
660
+ <span aria-hidden="true">i</span>
661
+ </button>
662
+ <span
663
+ id={tooltipId}
664
+ role="tooltip"
665
+ className="pointer-events-none absolute left-1/2 top-full z-50 mt-2 w-64 -translate-x-1/2 rounded-md bg-gray-900 px-3 py-2 text-xs text-gray-50 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
666
+ >
667
+ {text}
668
+ </span>
669
+ </span>
670
+ );
671
+ };
672
+ export function CurrentVisitors({ siteId }: { siteId: number }) {
673
+ const { data, isFetching } = useQuery({
674
+ queryKey: ["currentVisitors", siteId],
675
+ queryFn: async () => {
676
+ const response = await fetch(
677
+ `/api/dashboard/current-visitors?site_id=${siteId}&windowSeconds=${60 * 5}`,
678
+ {
679
+ method: "GET",
680
+ headers: { "Content-Type": "application/json" },
681
+ },
682
+ );
683
+
684
+ let payload: unknown = null;
685
+ try {
686
+ payload = await response.json();
687
+ } catch {
688
+ payload = null;
689
+ }
690
+
691
+ if (!response.ok) {
692
+ const messageFromApi =
693
+ typeof payload === "object" &&
694
+ payload !== null &&
695
+ "error" in payload &&
696
+ typeof (payload as { error: unknown }).error === "string"
697
+ ? (payload as { error: string }).error
698
+ : null;
699
+
700
+ const requestIdFromApi =
701
+ typeof payload === "object" &&
702
+ payload !== null &&
703
+ "requestId" in payload &&
704
+ typeof (payload as { requestId?: unknown }).requestId === "string"
705
+ ? (payload as { requestId: string }).requestId
706
+ : null;
707
+
708
+ const baseMessage =
709
+ messageFromApi ||
710
+ `Failed to fetch current visitors (HTTP ${response.status})`;
711
+
712
+ throw new Error(
713
+ requestIdFromApi ? `${baseMessage} (requestId: ${requestIdFromApi})` : baseMessage,
714
+ );
715
+ }
716
+
717
+ return payload as CurrentVisitorsResponse;
718
+ },
719
+ enabled: !!siteId,
720
+ refetchInterval: 10_000,
721
+ staleTime: 0,
722
+ });
723
+
724
+ const currentVisitors = data?.currentVisitors;
725
+
726
+ return (
727
+ <div className="flex items-center space-x-2">
728
+ <svg
729
+ aria-hidden="true"
730
+ focusable="false"
731
+ className="h-3 w-3 fill-current text-(--color-secondary)"
732
+ viewBox="0 0 8 8"
733
+ >
734
+ <circle cx="4" cy="4" r="3" />
735
+ </svg>
736
+ <span className="text-sm text-(--theme-text-secondary)">
737
+ {typeof currentVisitors === "number" ? currentVisitors : "—"} Current Visitors
738
+ </span>
739
+ <HelpTooltip text="Approximate distinct visitors in the last 5 minutes. Updates every 10 seconds." />
740
+ {isFetching && (
741
+ <SpinnerIcon className="w-3 h-3 text-(--theme-text-secondary) animate-spin" />
742
+ )}
743
+ </div>
744
+ );
745
+ }
746
+
747
+ export const getFocusableElements = (container: HTMLElement | null): HTMLElement[] => {
748
+ if (!container) return [];
749
+
750
+ const selectors = [
751
+ "a[href]",
752
+ "button:not([disabled])",
753
+ "input:not([disabled])",
754
+ "select:not([disabled])",
755
+ "textarea:not([disabled])",
756
+ "[tabindex]:not([tabindex='-1'])",
757
+ ];
758
+
759
+ return Array.from(
760
+ container.querySelectorAll<HTMLElement>(selectors.join(",")),
761
+ ).filter(
762
+ (element) =>
763
+ !element.hasAttribute("disabled") &&
764
+ element.getAttribute("aria-hidden") !== "true",
765
+ );
766
+ };
767
+
768
+ export const FilterModal: React.FC<{
769
+ filters: DashboardFilters;
770
+ onFiltersChange: (filters: DashboardFilters) => void;
771
+ isOpen: boolean;
772
+ onClose: () => void;
773
+ onNotify: (notice: DashboardNotice) => void;
774
+ deviceTypeOptions?: string[];
775
+ countryOptions?: string[];
776
+ cityOptions?: string[];
777
+ regionOptions?: string[];
778
+ sourceOptions?: string[];
779
+ pageUrlOptions?: string[];
780
+ eventNameOptions?: string[];
781
+ }> = ({ filters, onFiltersChange, isOpen, onClose, deviceTypeOptions = [], countryOptions = [], cityOptions = [], regionOptions = [], sourceOptions = [], pageUrlOptions = [], eventNameOptions = [] }) => {
782
+ const titleId = useId();
783
+ const deviceTypeId = useId();
784
+ const countryId = useId();
785
+ const cityId = useId();
786
+ const regionId = useId();
787
+ const sourceId = useId();
788
+ const pageUrlId = useId();
789
+ const eventNameId = useId();
790
+ const modalRef = useRef<HTMLDivElement | null>(null);
791
+ const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
792
+
793
+ const updateFilter = (patch: Partial<DashboardFilters>) => {
794
+ onFiltersChange({ ...filters, ...patch });
795
+ };
796
+
797
+ const handleClearFilters = () => {
798
+ onFiltersChange({
799
+ dateRange: filters.dateRange,
800
+ deviceType: undefined,
801
+ country: undefined,
802
+ city: undefined,
803
+ region: undefined,
804
+ source: undefined,
805
+ pageUrl: undefined,
806
+ eventName: undefined,
807
+ siteId: filters.siteId,
808
+ });
809
+ };
810
+
811
+ useEffect(() => {
812
+ if (!isOpen) return;
813
+
814
+ previouslyFocusedElementRef.current = document.activeElement as HTMLElement | null;
815
+
816
+ // Wait a tick so the modal is in the DOM.
817
+ const frame = requestAnimationFrame(() => {
818
+ const focusable = getFocusableElements(modalRef.current);
819
+ (focusable[0] ?? modalRef.current)?.focus();
820
+ });
821
+
822
+ return () => {
823
+ cancelAnimationFrame(frame);
824
+ previouslyFocusedElementRef.current?.focus();
825
+ };
826
+ }, [isOpen]);
827
+
828
+ const handleKeyDown = (event: React.KeyboardEvent) => {
829
+ if (event.key === "Escape") {
830
+ event.preventDefault();
831
+ event.stopPropagation();
832
+ onClose();
833
+ return;
834
+ }
835
+
836
+ if (event.key !== "Tab") return;
837
+
838
+ const focusable = getFocusableElements(modalRef.current);
839
+ if (focusable.length === 0) return;
840
+
841
+ const first = focusable[0];
842
+ const last = focusable[focusable.length - 1];
843
+ const active = document.activeElement as HTMLElement | null;
844
+
845
+ if (event.shiftKey) {
846
+ if (!active || active === first) {
847
+ event.preventDefault();
848
+ last.focus();
849
+ }
850
+ return;
851
+ }
852
+
853
+ if (!active || active === last) {
854
+ event.preventDefault();
855
+ first.focus();
856
+ }
857
+ };
858
+
859
+ if (!isOpen) return null;
860
+
861
+ return (
862
+ <div
863
+ className="fixed inset-0 bg-black/50 flex items-stretch justify-end z-50"
864
+ role="presentation"
865
+ onMouseDown={(event) => {
866
+ if (event.target === event.currentTarget) {
867
+ onClose();
868
+ }
869
+ }}
870
+ >
871
+ <div
872
+ ref={modalRef}
873
+ role="dialog"
874
+ aria-modal="true"
875
+ aria-labelledby={titleId}
876
+ tabIndex={-1}
877
+ onKeyDown={handleKeyDown}
878
+ className="bg-(--theme-bg-secondary) h-full w-full max-w-88 sm:max-w-104 md:max-w-120 p-6 shadow-xl border-l border-(--theme-border-primary) overflow-y-auto sm:rounded-l-xl"
879
+ >
880
+ <div className="flex items-center justify-between mb-4">
881
+ <h2
882
+ id={titleId}
883
+ className="text-xl font-semibold text-(--theme-text-primary)"
884
+ >
885
+ Filters
886
+ </h2>
887
+ <button
888
+ type="button"
889
+ onClick={onClose}
890
+ className="text-(--theme-text-secondary) hover:text-(--theme-text-primary) transition-colors focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary) rounded"
891
+ aria-label="Close filters"
892
+ >
893
+ <svg
894
+ aria-hidden="true"
895
+ focusable="false"
896
+ className="w-6 h-6"
897
+ fill="none"
898
+ stroke="currentColor"
899
+ viewBox="0 0 24 24"
900
+ >
901
+ <path
902
+ strokeLinecap="round"
903
+ strokeLinejoin="round"
904
+ strokeWidth={2}
905
+ d="M6 18L18 6M6 6l12 12"
906
+ />
907
+ </svg>
908
+ </button>
909
+ </div>
910
+
911
+ <div className="space-y-4">
912
+ {/* Device Type Filter */}
913
+ <div>
914
+ <label
915
+ htmlFor={deviceTypeId}
916
+ className="block text-sm font-medium text-(--theme-text-primary) mb-2"
917
+ >
918
+ Device Type
919
+ </label>
920
+ <select
921
+ id={deviceTypeId}
922
+ value={filters.deviceType || ""}
923
+ onChange={(e) =>
924
+ updateFilter({ deviceType: e.target.value || undefined })
925
+ }
926
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
927
+ >
928
+ <option value="">All Devices</option>
929
+ {deviceTypeOptions.map((dt) => (
930
+ <option key={dt} value={dt}>{dt}</option>
931
+ ))}
932
+ </select>
933
+ </div>
934
+
935
+ {/* Country Filter */}
936
+ <div>
937
+ <label
938
+ htmlFor={countryId}
939
+ className="block text-sm font-medium text-(--theme-text-primary) mb-2"
940
+ >
941
+ Country
942
+ </label>
943
+ <select
944
+ id={countryId}
945
+ value={filters.country || ""}
946
+ onChange={(e) =>
947
+ updateFilter({ country: e.target.value || undefined })
948
+ }
949
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
950
+ >
951
+ <option value="">All Countries</option>
952
+ {countryOptions.map((country) => (
953
+ <option key={country} value={country}>{country}</option>
954
+ ))}
955
+ </select>
956
+ </div>
957
+
958
+ {/* Region Filter */}
959
+ <div>
960
+ <label
961
+ htmlFor={regionId}
962
+ className="block text-sm font-medium text-(--theme-text-primary) mb-2"
963
+ >
964
+ Region
965
+ </label>
966
+ <select
967
+ id={regionId}
968
+ value={filters.region || ""}
969
+ onChange={(e) =>
970
+ updateFilter({ region: e.target.value || undefined })
971
+ }
972
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
973
+ >
974
+ <option value="">All Regions</option>
975
+ {regionOptions.map((r) => (
976
+ <option key={r} value={r}>{r}</option>
977
+ ))}
978
+ </select>
979
+ </div>
980
+
981
+ {/* City Filter */}
982
+ <div>
983
+ <label
984
+ htmlFor={cityId}
985
+ className="block text-sm font-medium text-(--theme-text-primary) mb-2"
986
+ >
987
+ City
988
+ </label>
989
+ <select
990
+ id={cityId}
991
+ value={filters.city || ""}
992
+ onChange={(e) =>
993
+ updateFilter({ city: e.target.value || undefined })
994
+ }
995
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
996
+ >
997
+ <option value="">All Cities</option>
998
+ {cityOptions.map((c) => (
999
+ <option key={c} value={c}>{c}</option>
1000
+ ))}
1001
+ </select>
1002
+ </div>
1003
+
1004
+ {/* Traffic Source Filter */}
1005
+ <div>
1006
+ <label
1007
+ htmlFor={sourceId}
1008
+ className="block text-sm font-medium text-(--theme-text-primary) mb-2"
1009
+ >
1010
+ Traffic Source
1011
+ </label>
1012
+ <select
1013
+ id={sourceId}
1014
+ value={filters.source || ""}
1015
+ onChange={(e) =>
1016
+ updateFilter({ source: e.target.value || undefined })
1017
+ }
1018
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
1019
+ >
1020
+ <option value="">All Sources</option>
1021
+ {sourceOptions.map((source) => (
1022
+ <option key={source} value={source}>{source}</option>
1023
+ ))}
1024
+ </select>
1025
+ </div>
1026
+
1027
+ {/* Page URL Filter */}
1028
+ <div>
1029
+ <label
1030
+ htmlFor={pageUrlId}
1031
+ className="block text-sm font-medium text-(--theme-text-primary) mb-2"
1032
+ >
1033
+ Page
1034
+ </label>
1035
+ <select
1036
+ id={pageUrlId}
1037
+ value={filters.pageUrl || ""}
1038
+ onChange={(e) =>
1039
+ updateFilter({ pageUrl: e.target.value || undefined })
1040
+ }
1041
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
1042
+ >
1043
+ <option value="">All Pages</option>
1044
+ {pageUrlOptions.map((page) => (
1045
+ <option key={page} value={page}>{page}</option>
1046
+ ))}
1047
+ </select>
1048
+ </div>
1049
+
1050
+ {/* Event Filter */}
1051
+ <div>
1052
+ <label
1053
+ htmlFor={eventNameId}
1054
+ className="block text-sm font-medium text-(--theme-text-primary) mb-2"
1055
+ >
1056
+ Event
1057
+ </label>
1058
+ <select
1059
+ id={eventNameId}
1060
+ value={filters.eventName || ""}
1061
+ onChange={(e) =>
1062
+ updateFilter({ eventName: e.target.value || undefined })
1063
+ }
1064
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
1065
+ >
1066
+ <option value="">All Events</option>
1067
+ {eventNameOptions.map((ev) => (
1068
+ <option key={ev} value={ev}>{ev}</option>
1069
+ ))}
1070
+ </select>
1071
+ </div>
1072
+
1073
+ <p className="text-xs text-(--theme-text-secondary)">
1074
+ Tip: Filters apply across all dashboard cards.
1075
+ </p>
1076
+ </div>
1077
+
1078
+ <div className="mt-6">
1079
+ <button
1080
+ type="button"
1081
+ onClick={handleClearFilters}
1082
+ className="w-full px-4 py-2 bg-(--theme-input-bg) text-(--theme-text-secondary) border border-(--theme-input-border) rounded hover:bg-(--theme-bg-secondary) transition-colors focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
1083
+ >
1084
+ Clear All
1085
+ </button>
1086
+ </div>
1087
+ </div>
1088
+ </div>
1089
+ );
1090
+ }
1091
+
1092
+
1093
+ export const DatePicker: React.FC<{
1094
+ dateRange: DateRange;
1095
+ onDateRangeChange: (range: DateRange) => void;
1096
+ isOpen: boolean;
1097
+ onToggle: () => void;
1098
+ timezone: string;
1099
+ }> = ({ dateRange, onDateRangeChange, isOpen, onToggle, timezone }) => {
1100
+ const titleId = useId();
1101
+ const startDateId = useId();
1102
+ const endDateId = useId();
1103
+ const containerRef = useRef<HTMLDivElement | null>(null);
1104
+ const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
1105
+ const [draft, setDraft] = useState<DateRange>(dateRange);
1106
+ const draftRef = useRef(draft);
1107
+ draftRef.current = draft;
1108
+
1109
+ const wasOpenRef = useRef(false);
1110
+
1111
+ useEffect(() => {
1112
+ if (isOpen) {
1113
+ setDraft(dateRange);
1114
+ wasOpenRef.current = true;
1115
+ } else if (wasOpenRef.current) {
1116
+ wasOpenRef.current = false;
1117
+ const d = draftRef.current;
1118
+ if (!d.preset && (d.start !== dateRange.start || d.end !== dateRange.end)) {
1119
+ onDateRangeChange(d);
1120
+ }
1121
+ }
1122
+ }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
1123
+
1124
+ useEffect(() => {
1125
+ if (!isOpen) return;
1126
+
1127
+ previouslyFocusedElementRef.current = document.activeElement as HTMLElement | null;
1128
+
1129
+ const frame = requestAnimationFrame(() => {
1130
+ const focusable = getFocusableElements(containerRef.current);
1131
+ (focusable[0] ?? containerRef.current)?.focus();
1132
+ });
1133
+
1134
+ return () => {
1135
+ cancelAnimationFrame(frame);
1136
+ previouslyFocusedElementRef.current?.focus();
1137
+ };
1138
+ }, [isOpen]);
1139
+
1140
+ useEffect(() => {
1141
+ if (!isOpen) return;
1142
+
1143
+ const handleClickOutside = (event: MouseEvent) => {
1144
+ const target = event.target as Node;
1145
+ const wrapper = containerRef.current?.parentElement;
1146
+ if (wrapper && !wrapper.contains(target)) {
1147
+ onToggle();
1148
+ }
1149
+ };
1150
+
1151
+ document.addEventListener("mousedown", handleClickOutside);
1152
+ return () => document.removeEventListener("mousedown", handleClickOutside);
1153
+ }, [isOpen, onToggle]);
1154
+
1155
+ const computePresetRange = (key: string): { start: string; end: string } => {
1156
+ const now = new Date();
1157
+ const today = getDateStringInTimeZone(now, timezone);
1158
+ const daysAgo = (n: number) => shiftDateString(today, -n);
1159
+ const todayParts = getDatePartsInTimeZone(now, timezone);
1160
+ const currentYear = todayParts.year;
1161
+ const currentMonth = todayParts.month;
1162
+
1163
+ switch (key) {
1164
+ case "Last 30 min": {
1165
+ const s = new Date(now.getTime() - 30 * 60 * 1000);
1166
+ return { start: s.toISOString(), end: now.toISOString() };
1167
+ }
1168
+ case "Last hour": {
1169
+ const s = new Date(now.getTime() - 60 * 60 * 1000);
1170
+ return { start: s.toISOString(), end: now.toISOString() };
1171
+ }
1172
+ case "Today":
1173
+ return { start: today, end: today };
1174
+ case "Yesterday": {
1175
+ const y = daysAgo(1);
1176
+ return { start: y, end: y };
1177
+ }
1178
+ case "Last 7 days":
1179
+ return { start: daysAgo(7), end: today };
1180
+ case "Last 30 days":
1181
+ return { start: daysAgo(30), end: today };
1182
+ case "Last 6 months":
1183
+ return { start: daysAgo(180), end: today };
1184
+ case "Last 12 months":
1185
+ return { start: daysAgo(365), end: today };
1186
+ case "Month to Date": {
1187
+ return {
1188
+ start: formatDateParts({ year: currentYear, month: currentMonth, day: 1 }),
1189
+ end: today,
1190
+ };
1191
+ }
1192
+ case "Last Month": {
1193
+ const firstOfCurrentMonthUtc = new Date(Date.UTC(currentYear, currentMonth - 1, 1));
1194
+ const firstOfLastMonthUtc = new Date(firstOfCurrentMonthUtc);
1195
+ firstOfLastMonthUtc.setUTCMonth(firstOfLastMonthUtc.getUTCMonth() - 1);
1196
+ const lastOfLastMonthUtc = new Date(firstOfCurrentMonthUtc);
1197
+ lastOfLastMonthUtc.setUTCDate(0);
1198
+ return {
1199
+ start: formatDateParts({
1200
+ year: firstOfLastMonthUtc.getUTCFullYear(),
1201
+ month: firstOfLastMonthUtc.getUTCMonth() + 1,
1202
+ day: firstOfLastMonthUtc.getUTCDate(),
1203
+ }),
1204
+ end: formatDateParts({
1205
+ year: lastOfLastMonthUtc.getUTCFullYear(),
1206
+ month: lastOfLastMonthUtc.getUTCMonth() + 1,
1207
+ day: lastOfLastMonthUtc.getUTCDate(),
1208
+ }),
1209
+ };
1210
+ }
1211
+ case "Year to Date": {
1212
+ return {
1213
+ start: formatDateParts({ year: currentYear, month: 1, day: 1 }),
1214
+ end: today,
1215
+ };
1216
+ }
1217
+ case "Last year": {
1218
+ const previousYear = currentYear - 1;
1219
+ return {
1220
+ start: formatDateParts({ year: previousYear, month: 1, day: 1 }),
1221
+ end: formatDateParts({ year: previousYear, month: 12, day: 31 }),
1222
+ };
1223
+ }
1224
+ default:
1225
+ return { start: daysAgo(7), end: today };
1226
+ }
1227
+ };
1228
+
1229
+ type PresetItem = { label: string; shortcut: string } | "separator";
1230
+
1231
+ const presetGroups: PresetItem[] = [
1232
+ { label: "Last 30 min", shortcut: "R" },
1233
+ { label: "Last hour", shortcut: "H" },
1234
+ { label: "Today", shortcut: "D" },
1235
+ { label: "Yesterday", shortcut: "E" },
1236
+ "separator",
1237
+ { label: "Last 7 days", shortcut: "W" },
1238
+ { label: "Last 30 days", shortcut: "T" },
1239
+ { label: "Last 6 months", shortcut: "6" },
1240
+ { label: "Last 12 months", shortcut: "0" },
1241
+ "separator",
1242
+ { label: "Month to Date", shortcut: "M" },
1243
+ { label: "Last Month", shortcut: "P" },
1244
+ "separator",
1245
+ { label: "Year to Date", shortcut: "Y" },
1246
+ { label: "Last year", shortcut: "U" },
1247
+ ];
1248
+
1249
+ const handlePresetClick = (label: string) => {
1250
+ const { start, end } = computePresetRange(label);
1251
+ const newRange: DateRange = { start, end, preset: label };
1252
+ setDraft(newRange);
1253
+ onDateRangeChange(newRange);
1254
+ onToggle();
1255
+ };
1256
+
1257
+ const handleCustomDateChange = (field: "start" | "end", value: string) => {
1258
+ setDraft((prev) => ({
1259
+ ...prev,
1260
+ [field]: value,
1261
+ preset: undefined,
1262
+ }));
1263
+ };
1264
+
1265
+ useKeybinds({
1266
+ binds: presetGroups
1267
+ .filter((item): item is { label: string; shortcut: string } => item !== "separator")
1268
+ .map((item) => ({
1269
+ key: item.shortcut,
1270
+ action: () => handlePresetClick(item.label),
1271
+ })),
1272
+ enabled: isOpen,
1273
+ });
1274
+
1275
+ if (!isOpen) return null;
1276
+
1277
+ return (
1278
+ <div
1279
+ ref={containerRef}
1280
+ role="dialog"
1281
+ aria-modal="false"
1282
+ aria-labelledby={titleId}
1283
+ tabIndex={-1}
1284
+ onKeyDown={(event) => {
1285
+ if (event.key === "Escape") {
1286
+ event.preventDefault();
1287
+ onToggle();
1288
+ }
1289
+
1290
+ if (event.key === "Tab") {
1291
+ const focusable = getFocusableElements(containerRef.current);
1292
+ if (focusable.length === 0) return;
1293
+
1294
+ const first = focusable[0];
1295
+ const last = focusable[focusable.length - 1];
1296
+ const active = document.activeElement as HTMLElement | null;
1297
+
1298
+ if (event.shiftKey) {
1299
+ if (!active || active === first) {
1300
+ event.preventDefault();
1301
+ last.focus();
1302
+ }
1303
+ return;
1304
+ }
1305
+
1306
+ if (!active || active === last) {
1307
+ event.preventDefault();
1308
+ first.focus();
1309
+ }
1310
+ }
1311
+ }}
1312
+ className="absolute top-full right-0 left-auto mt-2 w-80 max-w-[calc(100vw-2rem)] bg-(--theme-bg-secondary) border border-(--theme-border-primary) rounded-lg shadow-lg z-50"
1313
+ >
1314
+ <div className="p-4">
1315
+ <h3
1316
+ id={titleId}
1317
+ className="text-sm font-medium text-(--theme-text-primary) mb-3"
1318
+ >
1319
+ Time window
1320
+ </h3>
1321
+
1322
+ {/* Preset Options */}
1323
+ <div className="mb-4">
1324
+ {presetGroups.map((item, idx) => {
1325
+ if (item === "separator") {
1326
+ return (
1327
+ <div
1328
+ key={`sep-${idx}`}
1329
+ className="my-1 border-t border-(--theme-border-primary)"
1330
+ />
1331
+ );
1332
+ }
1333
+ return (
1334
+ <button
1335
+ key={item.label}
1336
+ type="button"
1337
+ onClick={() => handlePresetClick(item.label)}
1338
+ className={`w-full flex items-center justify-between px-3 py-2 rounded text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary) ${dateRange.preset === item.label
1339
+ ? "bg-(--theme-button-bg) text-white"
1340
+ : "text-(--theme-text-secondary) hover:bg-(--theme-bg-tertiary)"
1341
+ }`}
1342
+ >
1343
+ <span>{item.label}</span>
1344
+ <kbd className={`text-xs font-mono ${dateRange.preset === item.label ? "text-white/60" : "text-(--theme-text-secondary) opacity-50"}`}>
1345
+ {item.shortcut}
1346
+ </kbd>
1347
+ </button>
1348
+ );
1349
+ })}
1350
+ </div>
1351
+
1352
+ {/* Custom Date Range */}
1353
+ <div className="border-t border-(--theme-border-primary) pt-4">
1354
+ <h4 className="text-xs font-medium text-(--theme-text-secondary) mb-2">
1355
+ Custom Range
1356
+ </h4>
1357
+ <div className="space-y-2">
1358
+ <div>
1359
+ <label
1360
+ htmlFor={startDateId}
1361
+ className="block text-xs text-(--theme-text-secondary) mb-1"
1362
+ >
1363
+ Start Date
1364
+ </label>
1365
+ <input
1366
+ id={startDateId}
1367
+ type="date"
1368
+ value={draft.start}
1369
+ onChange={(e) =>
1370
+ handleCustomDateChange("start", e.target.value)
1371
+ }
1372
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
1373
+ />
1374
+ </div>
1375
+ <div>
1376
+ <label
1377
+ htmlFor={endDateId}
1378
+ className="block text-xs text-(--theme-text-secondary) mb-1"
1379
+ >
1380
+ End Date
1381
+ </label>
1382
+ <input
1383
+ id={endDateId}
1384
+ type="date"
1385
+ value={draft.end}
1386
+ onChange={(e) => handleCustomDateChange("end", e.target.value)}
1387
+ className="w-full px-3 py-2 bg-(--theme-input-bg) border border-(--theme-input-border) rounded text-sm text-(--theme-text-primary) focus:border-(--theme-border-primary) focus:outline-none"
1388
+ />
1389
+ </div>
1390
+ </div>
1391
+ </div>
1392
+ </div>
1393
+ </div>
1394
+ );
1395
+ };
1396
+
1397
+ export const Scorecard: React.FC<ScorecardProps> = ({
1398
+ title,
1399
+ value,
1400
+ change,
1401
+ changeType,
1402
+ changeLabel,
1403
+ }) => {
1404
+ const getChangeIcon = () => {
1405
+ if (changeType === "positive") {
1406
+ return (
1407
+ <svg
1408
+ aria-hidden="true"
1409
+ focusable="false"
1410
+ className="h-4 w-4 text-(--color-secondary) mr-1"
1411
+ fill="currentColor"
1412
+ viewBox="0 0 20 20"
1413
+ >
1414
+ <path
1415
+ fillRule="evenodd"
1416
+ d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
1417
+ clipRule="evenodd"
1418
+ />
1419
+ </svg>
1420
+ );
1421
+ } else if (changeType === "negative") {
1422
+ return (
1423
+ <svg
1424
+ aria-hidden="true"
1425
+ focusable="false"
1426
+ className="h-4 w-4 text-danger mr-1"
1427
+ fill="currentColor"
1428
+ viewBox="0 0 20 20"
1429
+ >
1430
+ <path
1431
+ fillRule="evenodd"
1432
+ d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
1433
+ clipRule="evenodd"
1434
+ />
1435
+ </svg>
1436
+ );
1437
+ } else {
1438
+ return (
1439
+ <svg
1440
+ aria-hidden="true"
1441
+ focusable="false"
1442
+ xmlns="http://www.w3.org/2000/svg"
1443
+ className="h-4 w-4 text-(--theme-text-secondary) mr-1"
1444
+ viewBox="0 0 20 20"
1445
+ fill="currentColor"
1446
+ >
1447
+ <rect y="9" width="20" height="2" />
1448
+ </svg>
1449
+ );
1450
+ }
1451
+ };
1452
+
1453
+ const getChangeColor = () => {
1454
+ if (changeType === "positive") return "text-(--color-secondary)";
1455
+ if (changeType === "negative") return "text-(--color-danger)";
1456
+ return "text-(--theme-text-secondary)";
1457
+ };
1458
+
1459
+ // Only show the change row if there's actual change data
1460
+ const hasChangeData = change !== "" || changeLabel !== "";
1461
+
1462
+ return (
1463
+ <div className="bg-(--theme-card-bg) border border-(--theme-card-border) rounded-lg p-4 text-left">
1464
+ <h3 className="text-xs font-medium text-(--theme-text-secondary) uppercase tracking-wider mb-1">
1465
+ {title}
1466
+ </h3>
1467
+ <p className="text-2xl font-bold text-(--theme-text-primary) mb-1">
1468
+ {value}
1469
+ </p>
1470
+ {hasChangeData && (
1471
+ <div className="flex items-center justify-start">
1472
+ {getChangeIcon()}
1473
+ <span className="text-xs text-(--theme-text-secondary)">
1474
+ <span className={getChangeColor()}>{change}</span> {changeLabel}
1475
+ </span>
1476
+ </div>
1477
+ )}
1478
+ </div>
1479
+ );
1480
+ };
1481
+