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,974 @@
1
+ "use client";
2
+
3
+ import { useContext, useEffect, useMemo, useState, useCallback, useRef } from "react";
4
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { SiteSelector } from "@components/SiteSelector";
6
+ import { AuthContext } from "@/app/providers/AuthProvider";
7
+ import type { DashboardResponseData } from "@db/tranformReports";
8
+ import type { EventLabelSelect } from "@db/d1/schema";
9
+
10
+ type EventSummaryData = NonNullable<DashboardResponseData["EventSummary"]>;
11
+
12
+ type EventSummaryRow = EventSummaryData["summary"][number];
13
+
14
+ type EventSummaryRowWithShare = EventSummaryRow & { share: number };
15
+
16
+ type EventTypeFilter = "all" | "autocapture" | "event_capture" | "page_view";
17
+ type EventActionFilter = "all" | "click" | "submit" | "change" | "rule";
18
+ type EventSortBy = "count" | "first_seen" | "last_seen";
19
+ type EventSortDirection = "asc" | "desc";
20
+
21
+ type DateParts = { year: number; month: number; day: number };
22
+
23
+ const isValidTimeZone = (value: unknown): value is string => {
24
+ if (typeof value !== "string" || value.trim().length === 0) return false;
25
+ try {
26
+ Intl.DateTimeFormat(undefined, { timeZone: value.trim() });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ };
32
+
33
+ const getBrowserTimeZone = (): string => {
34
+ const guessed = Intl.DateTimeFormat().resolvedOptions().timeZone;
35
+ return isValidTimeZone(guessed) ? guessed : "UTC";
36
+ };
37
+
38
+ const formatDateParts = ({ year, month, day }: DateParts): string => {
39
+ const mm = String(month).padStart(2, "0");
40
+ const dd = String(day).padStart(2, "0");
41
+ return `${year}-${mm}-${dd}`;
42
+ };
43
+
44
+ const getDatePartsInTimeZone = (date: Date, timeZone: string): DateParts => {
45
+ const formatter = new Intl.DateTimeFormat("en-CA", {
46
+ timeZone,
47
+ year: "numeric",
48
+ month: "2-digit",
49
+ day: "2-digit",
50
+ });
51
+
52
+ const parts = formatter.formatToParts(date);
53
+ const year = Number(parts.find((part) => part.type === "year")?.value);
54
+ const month = Number(parts.find((part) => part.type === "month")?.value);
55
+ const day = Number(parts.find((part) => part.type === "day")?.value);
56
+
57
+ return {
58
+ year: Number.isFinite(year) ? year : date.getUTCFullYear(),
59
+ month: Number.isFinite(month) ? month : date.getUTCMonth() + 1,
60
+ day: Number.isFinite(day) ? day : date.getUTCDate(),
61
+ };
62
+ };
63
+
64
+ const getDateStringInTimeZone = (date: Date, timeZone: string): string => {
65
+ return formatDateParts(getDatePartsInTimeZone(date, timeZone));
66
+ };
67
+
68
+ const shiftDateString = (dateString: string, days: number): string => {
69
+ const [year, month, day] = dateString.split("-").map((value) => Number(value));
70
+ const shifted = new Date(Date.UTC(year, month - 1, day));
71
+ shifted.setUTCDate(shifted.getUTCDate() + days);
72
+ return formatDateParts({
73
+ year: shifted.getUTCFullYear(),
74
+ month: shifted.getUTCMonth() + 1,
75
+ day: shifted.getUTCDate(),
76
+ });
77
+ };
78
+
79
+ const formatEventDate = (value: string | null, timezone: string) => {
80
+ if (!value) return "-";
81
+ const date = new Date(value);
82
+ if (Number.isNaN(date.getTime())) return "-";
83
+ try {
84
+ return date.toLocaleString(undefined, {
85
+ timeZone: timezone,
86
+ month: "numeric",
87
+ day: "numeric",
88
+ year: "numeric",
89
+ hour: "numeric",
90
+ minute: "2-digit",
91
+ });
92
+ } catch {
93
+ return date.toLocaleString(undefined, {
94
+ month: "numeric",
95
+ day: "numeric",
96
+ year: "numeric",
97
+ hour: "numeric",
98
+ minute: "2-digit",
99
+ });
100
+ }
101
+ };
102
+
103
+ const formatEventShare = (share: number) => {
104
+ if (!Number.isFinite(share) || share <= 0) return "0%";
105
+ if (share < 1) return "<1%";
106
+ return `${share.toFixed(0)}%`;
107
+ };
108
+
109
+ /** Check if an event is an autocapture event */
110
+ const isAutocaptureEvent = (eventName: string | null): boolean => {
111
+ return eventName?.startsWith("$ac_") ?? false;
112
+ };
113
+
114
+ /** Check if an event is a rule capture event */
115
+ const isRuleCaptureEvent = (eventName: string | null): boolean => {
116
+ return eventName === "auto_capture";
117
+ };
118
+
119
+ const isManualCaptureEvent = (eventName: string | null): boolean => {
120
+ if (!eventName) return false;
121
+ if (isAutocaptureEvent(eventName) || isRuleCaptureEvent(eventName)) return false;
122
+ return eventName !== "page_view";
123
+ };
124
+
125
+ /** Parse autocapture event name into parts */
126
+ const parseAutocaptureEvent = (eventName: string): {
127
+ elementType: string;
128
+ elementText: string;
129
+ elementId: string | null;
130
+ } => {
131
+ // Format: $ac_link_ElementText_elementId or $ac_form_FormName_formId
132
+ const parts = eventName.split('_');
133
+ // parts[0] = "$ac", parts[1] = type, parts[2] = text, parts[3] = id (optional)
134
+ const elementType = parts[1] || 'unknown';
135
+ const elementText = parts[2] || 'unnamed';
136
+ const elementId = parts[3] || null;
137
+
138
+ return { elementType, elementText, elementId };
139
+ };
140
+
141
+ /** Get a human-readable display name for autocapture events */
142
+ const getAutocaptureDisplayName = (eventName: string): string => {
143
+ const { elementText, elementId } = parseAutocaptureEvent(eventName);
144
+
145
+ // Show: "Element Text" or "Element Text_id" if id exists
146
+ if (elementId) {
147
+ return `${elementText}_${elementId}`;
148
+ }
149
+ return elementText;
150
+ };
151
+
152
+ /** Get element type badge text */
153
+ const getAutocaptureTypeBadge = (eventName: string): string => {
154
+ const { elementType } = parseAutocaptureEvent(eventName);
155
+ // Capitalize first letter
156
+ return elementType.charAt(0).toUpperCase() + elementType.slice(1);
157
+ };
158
+
159
+ /** Badge component for event type */
160
+ const EventTypeBadge = ({
161
+ isAutocapture,
162
+ eventName,
163
+ }: {
164
+ isAutocapture: boolean;
165
+ eventName: string | null;
166
+ }) => {
167
+ if (isAutocapture && eventName) {
168
+ const typeBadge = getAutocaptureTypeBadge(eventName);
169
+ return (
170
+ <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
171
+ {typeBadge}
172
+ </span>
173
+ );
174
+ }
175
+ return null;
176
+ };
177
+
178
+ const getAutocaptureMethod = (eventName: string): string => {
179
+ const { elementType } = parseAutocaptureEvent(eventName);
180
+ if (elementType === "form") return "Submit";
181
+ if (elementType === "input") return "Change";
182
+ return "Click";
183
+ };
184
+
185
+ const getCaptureMeta = (
186
+ eventName: string | null,
187
+ ): { label: string; method?: string } | null => {
188
+ if (isAutocaptureEvent(eventName)) {
189
+ return {
190
+ label: "Auto Capture",
191
+ method: eventName ? getAutocaptureMethod(eventName) : undefined,
192
+ };
193
+ }
194
+ if (isRuleCaptureEvent(eventName)) {
195
+ return { label: "Auto Capture", method: "Rule" };
196
+ }
197
+ if (isManualCaptureEvent(eventName)) {
198
+ return { label: "Event Capture" };
199
+ }
200
+ return null;
201
+ };
202
+
203
+ const CaptureMethodBadge = ({ eventName }: { eventName: string | null }) => {
204
+ const meta = getCaptureMeta(eventName);
205
+ if (!meta?.method) return null;
206
+ return (
207
+ <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
208
+ {meta.method}
209
+ </span>
210
+ );
211
+ };
212
+
213
+ /** Pencil icon for edit button */
214
+ const PencilIcon = () => (
215
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
216
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
217
+ </svg>
218
+ );
219
+
220
+ /** Check icon for save button */
221
+ const CheckIcon = () => (
222
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
223
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
224
+ </svg>
225
+ );
226
+
227
+ /** X icon for cancel button */
228
+ const XIcon = () => (
229
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
230
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
231
+ </svg>
232
+ );
233
+
234
+ /** Trash icon for delete button */
235
+ const TrashIcon = () => (
236
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
237
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
238
+ </svg>
239
+ );
240
+
241
+ /** Inline label editor component */
242
+ const LabelEditor = ({
243
+ eventName,
244
+ currentLabel,
245
+ siteId,
246
+ onSave,
247
+ onDelete,
248
+ isSaving,
249
+ }: {
250
+ eventName: string;
251
+ currentLabel: string | null;
252
+ siteId: number;
253
+ onSave: (eventName: string, label: string) => void;
254
+ onDelete: (eventName: string) => void;
255
+ isSaving: boolean;
256
+ }) => {
257
+ const [isEditing, setIsEditing] = useState(false);
258
+ const [editValue, setEditValue] = useState(currentLabel || "");
259
+ const inputRef = useRef<HTMLInputElement>(null);
260
+
261
+ useEffect(() => {
262
+ if (isEditing && inputRef.current) {
263
+ inputRef.current.focus();
264
+ inputRef.current.select();
265
+ }
266
+ }, [isEditing]);
267
+
268
+ const handleSave = () => {
269
+ const trimmed = editValue.trim();
270
+ if (trimmed) {
271
+ onSave(eventName, trimmed);
272
+ }
273
+ setIsEditing(false);
274
+ };
275
+
276
+ const handleCancel = () => {
277
+ setEditValue(currentLabel || "");
278
+ setIsEditing(false);
279
+ };
280
+
281
+ const handleDelete = () => {
282
+ onDelete(eventName);
283
+ setEditValue("");
284
+ setIsEditing(false);
285
+ };
286
+
287
+ const handleKeyDown = (e: React.KeyboardEvent) => {
288
+ if (e.key === "Enter") {
289
+ handleSave();
290
+ } else if (e.key === "Escape") {
291
+ handleCancel();
292
+ }
293
+ };
294
+
295
+ if (isEditing) {
296
+ return (
297
+ <div className="flex items-center gap-1.5 mt-1">
298
+ <input
299
+ ref={inputRef}
300
+ type="text"
301
+ value={editValue}
302
+ onChange={(e) => setEditValue(e.target.value)}
303
+ onKeyDown={handleKeyDown}
304
+ placeholder="Enter custom label..."
305
+ className="flex-1 px-2 py-1 text-xs rounded border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] text-[var(--theme-text-primary)] min-w-[120px]"
306
+ disabled={isSaving}
307
+ />
308
+ <button
309
+ type="button"
310
+ onClick={handleSave}
311
+ disabled={isSaving || !editValue.trim()}
312
+ className="p-1 rounded text-green-500 hover:bg-green-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
313
+ title="Save label"
314
+ >
315
+ <CheckIcon />
316
+ </button>
317
+ <button
318
+ type="button"
319
+ onClick={handleCancel}
320
+ disabled={isSaving}
321
+ className="p-1 rounded text-[var(--theme-text-secondary)] hover:bg-[var(--theme-bg-secondary)]"
322
+ title="Cancel"
323
+ >
324
+ <XIcon />
325
+ </button>
326
+ {currentLabel && (
327
+ <button
328
+ type="button"
329
+ onClick={handleDelete}
330
+ disabled={isSaving}
331
+ className="p-1 rounded text-red-500 hover:bg-red-500/10 disabled:opacity-50"
332
+ title="Remove label"
333
+ >
334
+ <TrashIcon />
335
+ </button>
336
+ )}
337
+ </div>
338
+ );
339
+ }
340
+
341
+ return (
342
+ <div className="flex items-center gap-1.5 group/label">
343
+ {currentLabel && (
344
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
345
+ {currentLabel}
346
+ </span>
347
+ )}
348
+ <button
349
+ type="button"
350
+ onClick={() => setIsEditing(true)}
351
+ className="p-1 rounded text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)] hover:bg-[var(--theme-bg-secondary)] opacity-0 group-hover/label:opacity-100 transition-opacity"
352
+ title={currentLabel ? "Edit label" : "Add label"}
353
+ >
354
+ <PencilIcon />
355
+ </button>
356
+ </div>
357
+ );
358
+ };
359
+
360
+ export function EventsPage() {
361
+ const authContext = useContext(AuthContext);
362
+ const { current_site, isPending: isSessionLoading, data: session } = authContext || {
363
+ current_site: null,
364
+ isPending: true,
365
+ data: null,
366
+ };
367
+ const queryClient = useQueryClient();
368
+ const browserTimezone = useMemo(() => getBrowserTimeZone(), []);
369
+ const savedTimezone = session?.timezone;
370
+ const effectiveTimezone = isValidTimeZone(savedTimezone)
371
+ ? savedTimezone
372
+ : browserTimezone;
373
+
374
+ const [dateRange, setDateRange] = useState({ start: "", end: "" });
375
+ const [searchTerm, setSearchTerm] = useState("");
376
+ const [eventTypeFilter, setEventTypeFilter] = useState<EventTypeFilter>("all");
377
+ const [eventActionFilter, setEventActionFilter] = useState<EventActionFilter>("all");
378
+ const [sortBy, setSortBy] = useState<EventSortBy>("count");
379
+ const [sortDirection, setSortDirection] = useState<EventSortDirection>("desc");
380
+ const [currentPage, setCurrentPage] = useState(1);
381
+ const hasInitializedDateRange = useRef(false);
382
+ const itemsPerPage = 25;
383
+ const offset = (currentPage - 1) * itemsPerPage;
384
+
385
+ useEffect(() => {
386
+ if (isSessionLoading || hasInitializedDateRange.current) return;
387
+
388
+ const endDate = getDateStringInTimeZone(new Date(), effectiveTimezone);
389
+ const startDate = shiftDateString(endDate, -30);
390
+
391
+ setDateRange({
392
+ start: startDate,
393
+ end: endDate,
394
+ });
395
+ hasInitializedDateRange.current = true;
396
+ }, [effectiveTimezone, isSessionLoading]);
397
+
398
+ useEffect(() => {
399
+ setCurrentPage(1);
400
+ }, [
401
+ dateRange.start,
402
+ dateRange.end,
403
+ searchTerm,
404
+ eventTypeFilter,
405
+ eventActionFilter,
406
+ sortBy,
407
+ sortDirection,
408
+ ]);
409
+
410
+ const handleSort = useCallback(
411
+ (column: EventSortBy) => {
412
+ if (sortBy === column) {
413
+ setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
414
+ return;
415
+ }
416
+
417
+ setSortBy(column);
418
+ setSortDirection("desc");
419
+ },
420
+ [sortBy],
421
+ );
422
+
423
+ const getSortArrow = useCallback(
424
+ (column: EventSortBy) => {
425
+ if (sortBy !== column) return "↕";
426
+ return sortDirection === "asc" ? "↑" : "↓";
427
+ },
428
+ [sortBy, sortDirection],
429
+ );
430
+
431
+ // Fetch event labels for this site
432
+ const labelsQuery = useQuery<EventLabelSelect[], Error>({
433
+ queryKey: ["event-labels", current_site?.id],
434
+ queryFn: async () => {
435
+ if (!current_site?.id) return [];
436
+ const response = await fetch(`/api/event-labels?site_id=${current_site.id}`);
437
+ if (!response.ok) {
438
+ throw new Error("Failed to fetch event labels");
439
+ }
440
+ return response.json();
441
+ },
442
+ enabled: Boolean(current_site?.id),
443
+ });
444
+
445
+ // Create a map of event names to labels for quick lookup
446
+ const labelsMap = useMemo(() => {
447
+ const map = new Map<string, string>();
448
+ if (labelsQuery.data) {
449
+ for (const label of labelsQuery.data) {
450
+ map.set(label.event_name, label.label);
451
+ }
452
+ }
453
+ return map;
454
+ }, [labelsQuery.data]);
455
+
456
+ // Save label mutation
457
+ const saveLabelMutation = useMutation({
458
+ mutationFn: async ({ eventName, label }: { eventName: string; label: string }) => {
459
+ const response = await fetch("/api/event-labels/save", {
460
+ method: "POST",
461
+ headers: { "Content-Type": "application/json" },
462
+ body: JSON.stringify({
463
+ site_id: current_site?.id,
464
+ event_name: eventName,
465
+ label: label,
466
+ }),
467
+ });
468
+ if (!response.ok) {
469
+ const data = await response.json() as { error?: string };
470
+ throw new Error(data.error || "Failed to save label");
471
+ }
472
+ return response.json() as Promise<EventLabelSelect>;
473
+ },
474
+ onSuccess: () => {
475
+ queryClient.invalidateQueries({ queryKey: ["event-labels", current_site?.id] });
476
+ },
477
+ });
478
+
479
+ // Delete label mutation
480
+ const deleteLabelMutation = useMutation({
481
+ mutationFn: async (eventName: string) => {
482
+ const response = await fetch("/api/event-labels/delete", {
483
+ method: "POST",
484
+ headers: { "Content-Type": "application/json" },
485
+ body: JSON.stringify({
486
+ site_id: current_site?.id,
487
+ event_name: eventName,
488
+ }),
489
+ });
490
+ if (!response.ok) {
491
+ const data = await response.json() as { error?: string };
492
+ throw new Error(data.error || "Failed to delete label");
493
+ }
494
+ return response.json() as Promise<{ success: boolean }>;
495
+ },
496
+ onSuccess: () => {
497
+ queryClient.invalidateQueries({ queryKey: ["event-labels", current_site?.id] });
498
+ },
499
+ });
500
+
501
+ const handleSaveLabel = useCallback((eventName: string, label: string) => {
502
+ saveLabelMutation.mutate({ eventName, label });
503
+ }, [saveLabelMutation]);
504
+
505
+ const handleDeleteLabel = useCallback((eventName: string) => {
506
+ deleteLabelMutation.mutate(eventName);
507
+ }, [deleteLabelMutation]);
508
+
509
+ const eventsQuery = useQuery<DashboardResponseData, Error>({
510
+ queryKey: [
511
+ "events-summary",
512
+ current_site?.id,
513
+ dateRange.start,
514
+ dateRange.end,
515
+ offset,
516
+ itemsPerPage,
517
+ searchTerm,
518
+ eventTypeFilter,
519
+ eventActionFilter,
520
+ sortBy,
521
+ sortDirection,
522
+ effectiveTimezone,
523
+ ],
524
+ queryFn: async () => {
525
+ if (!current_site?.id) {
526
+ throw new Error("Select a site to view events.");
527
+ }
528
+
529
+ const response = await fetch("/api/dashboard/data", {
530
+ method: "POST",
531
+ headers: { "Content-Type": "application/json" },
532
+ body: JSON.stringify({
533
+ site_id: current_site.id,
534
+ date_start: dateRange.start,
535
+ date_end: dateRange.end,
536
+ timezone: effectiveTimezone,
537
+ event_summary_offset: offset,
538
+ event_summary_limit: itemsPerPage,
539
+ event_summary_search: searchTerm || undefined,
540
+ event_summary_type: eventTypeFilter,
541
+ event_summary_action: eventActionFilter,
542
+ event_summary_sort_by: sortBy,
543
+ event_summary_sort_direction: sortDirection,
544
+ }),
545
+ });
546
+
547
+ const payload = (await response.json().catch(() => null)) as
548
+ | DashboardResponseData
549
+ | { error?: string; requestId?: string }
550
+ | null;
551
+
552
+ if (!response.ok) {
553
+ const message =
554
+ payload && "error" in payload && typeof payload.error === "string"
555
+ ? payload.error
556
+ : response.statusText;
557
+ const requestId =
558
+ payload && "requestId" in payload && typeof payload.requestId === "string"
559
+ ? payload.requestId
560
+ : null;
561
+ throw new Error(requestId ? `${message} (requestId: ${requestId})` : message);
562
+ }
563
+
564
+ return payload as DashboardResponseData;
565
+ },
566
+ enabled: Boolean(current_site?.id && dateRange.start && dateRange.end),
567
+ });
568
+
569
+ const eventSummary = eventsQuery.data?.EventSummary ?? null;
570
+ const totalEvents = eventSummary?.totalEvents ?? 0;
571
+ const summaryRows = useMemo((): EventSummaryRowWithShare[] => {
572
+ const rows = eventSummary?.summary ?? [];
573
+ return rows.map((row) => ({
574
+ ...row,
575
+ share: totalEvents > 0 ? (row.count / totalEvents) * 100 : 0,
576
+ }));
577
+ }, [eventSummary, totalEvents]);
578
+ const isDateRangeReady = Boolean(dateRange.start && dateRange.end);
579
+ const totalPages = eventSummary?.pagination
580
+ ? Math.max(1, Math.ceil(eventSummary.pagination.total / eventSummary.pagination.limit))
581
+ : 1;
582
+ const currentSummaryPage = eventSummary?.pagination
583
+ ? Math.floor(eventSummary.pagination.offset / eventSummary.pagination.limit) + 1
584
+ : 1;
585
+
586
+ if (isSessionLoading) {
587
+ return (
588
+ <div className="flex flex-col min-h-screen">
589
+ <main className="flex-1 p-6">
590
+ <div className="flex items-center justify-center py-12">
591
+ <span className="text-[var(--theme-text-secondary)]">
592
+ Loading session...
593
+ </span>
594
+ </div>
595
+ </main>
596
+ </div>
597
+ );
598
+ }
599
+
600
+ if (!current_site?.id) {
601
+ return (
602
+ <div className="flex flex-col min-h-screen">
603
+ <main className="flex-1 p-6">
604
+ <div className="flex items-center justify-center py-12">
605
+ <span className="text-[var(--theme-text-secondary)]">
606
+ Select a site to view event analytics.
607
+ </span>
608
+ </div>
609
+ </main>
610
+ </div>
611
+ );
612
+ }
613
+
614
+ return (
615
+ <div className="flex flex-col min-h-screen">
616
+ <div className="sticky top-0 z-40 w-full border-t border-b border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] px-4 py-4 sm:p-6 shadow-[0_6px_14px_rgba(0,0,0,0.12)]">
617
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
618
+ <div className="text-[var(--theme-text-primary)] font-semibold">
619
+ <SiteSelector />
620
+ </div>
621
+ <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
622
+ <div className="flex items-center gap-2">
623
+ <label className="text-xs text-[var(--theme-text-secondary)]">
624
+ Start
625
+ </label>
626
+ <input
627
+ type="date"
628
+ value={dateRange.start}
629
+ onChange={(event) =>
630
+ setDateRange((prev) => ({ ...prev, start: event.target.value }))
631
+ }
632
+ className="px-3 py-2 text-sm rounded-md border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] text-[var(--theme-text-primary)]"
633
+ />
634
+ </div>
635
+ <div className="flex items-center gap-2">
636
+ <label className="text-xs text-[var(--theme-text-secondary)]">End</label>
637
+ <input
638
+ type="date"
639
+ value={dateRange.end}
640
+ onChange={(event) =>
641
+ setDateRange((prev) => ({ ...prev, end: event.target.value }))
642
+ }
643
+ className="px-3 py-2 text-sm rounded-md border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] text-[var(--theme-text-primary)]"
644
+ />
645
+ </div>
646
+ <div className="flex items-center gap-2">
647
+ <label className="text-xs text-[var(--theme-text-secondary)]">
648
+ Search
649
+ </label>
650
+ <input
651
+ type="search"
652
+ value={searchTerm}
653
+ onChange={(event) => setSearchTerm(event.target.value)}
654
+ placeholder="Event name"
655
+ className="w-full sm:w-48 px-3 py-2 text-sm rounded-md border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] text-[var(--theme-text-primary)]"
656
+ />
657
+ </div>
658
+ <div className="flex items-center gap-2">
659
+ <label className="text-xs text-[var(--theme-text-secondary)]">Type</label>
660
+ <select
661
+ value={eventTypeFilter}
662
+ onChange={(event) => setEventTypeFilter(event.target.value as EventTypeFilter)}
663
+ className="px-3 py-2 text-sm rounded-md border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] text-[var(--theme-text-primary)]"
664
+ >
665
+ <option value="all">All types</option>
666
+ <option value="autocapture">Auto Capture</option>
667
+ <option value="event_capture">Event Capture</option>
668
+ <option value="page_view">Page View</option>
669
+ </select>
670
+ </div>
671
+ <div className="flex items-center gap-2">
672
+ <label className="text-xs text-[var(--theme-text-secondary)]">Action</label>
673
+ <select
674
+ value={eventActionFilter}
675
+ onChange={(event) => setEventActionFilter(event.target.value as EventActionFilter)}
676
+ className="px-3 py-2 text-sm rounded-md border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] text-[var(--theme-text-primary)]"
677
+ >
678
+ <option value="all">All actions</option>
679
+ <option value="click">Click</option>
680
+ <option value="submit">Submit</option>
681
+ <option value="change">Change</option>
682
+ <option value="rule">Rule</option>
683
+ </select>
684
+ </div>
685
+ </div>
686
+ </div>
687
+
688
+ </div>
689
+
690
+ <main className="flex-1 p-4 sm:p-6 lg:p-8">
691
+ <div className="max-w-6xl mx-auto">
692
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between mb-6">
693
+ <div>
694
+ <h1 className="text-2xl font-bold text-[var(--theme-text-primary)]">
695
+ Events
696
+ </h1>
697
+ <p className="text-sm text-[var(--theme-text-secondary)]">
698
+ Captured events grouped by name, including auto-capture and custom events.
699
+ </p>
700
+ <div className="mt-2 inline-flex items-center rounded-md border border-[var(--theme-border-primary)] bg-[var(--theme-bg-secondary)] px-2.5 py-1 text-xs text-[var(--theme-text-secondary)]">
701
+ Hover over events to add custom labels.
702
+ </div>
703
+ </div>
704
+ <div className="text-sm text-[var(--theme-text-secondary)]">
705
+ <span className="font-semibold text-[var(--theme-text-primary)]">
706
+ {totalEvents.toLocaleString()}
707
+ </span>{" "}
708
+ total events{" "}
709
+ <span className="font-semibold text-[var(--theme-text-primary)]">
710
+ {(eventSummary?.totalEventTypes ?? 0).toLocaleString()}
711
+ </span>{" "}
712
+ event types
713
+ <span className="ml-2 text-xs text-[var(--theme-text-secondary)]">
714
+ Page {currentSummaryPage} of {totalPages}
715
+ </span>
716
+ </div>
717
+ </div>
718
+
719
+ {/* Custom Event Capture Guide */}
720
+ <details className="mb-6 rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-card-bg)]">
721
+ <summary className="px-4 py-3 cursor-pointer text-sm font-medium text-[var(--theme-text-primary)] hover:bg-[var(--theme-bg-secondary)] rounded-lg">
722
+ How to capture custom events
723
+ </summary>
724
+ <div className="px-4 pb-4 pt-2 border-t border-[var(--theme-border-primary)]">
725
+ <p className="text-sm text-[var(--theme-text-secondary)] mb-3">
726
+ Use the Lytx API to track custom events from your website:
727
+ </p>
728
+ <div className="bg-[var(--theme-bg-secondary)] rounded-md p-3 font-mono text-sm overflow-x-auto">
729
+ <div className="text-[var(--theme-text-secondary)] mb-2">// Basic event</div>
730
+ <div className="text-[var(--theme-text-primary)]">window.lytxApi.capture(<span className="text-green-500">"button_click"</span>)</div>
731
+ <div className="text-[var(--theme-text-secondary)] mt-3 mb-2">// Event with custom data</div>
732
+ <div className="text-[var(--theme-text-primary)]">window.lytxApi.capture(<span className="text-green-500">"purchase"</span>, {"{"}</div>
733
+ <div className="text-[var(--theme-text-primary)] pl-4">product_id: <span className="text-green-500">"123"</span>,</div>
734
+ <div className="text-[var(--theme-text-primary)] pl-4">value: <span className="text-green-500">"49.99"</span></div>
735
+ <div className="text-[var(--theme-text-primary)]">{"}"})</div>
736
+ </div>
737
+ <p className="text-xs text-[var(--theme-text-secondary)] mt-3">
738
+ <span className="font-medium">Tip:</span> Add <code className="bg-[var(--theme-bg-secondary)] px-1 rounded">?lytxDebug</code> to your URL to enable debug mode and see events in the console.
739
+ </p>
740
+ </div>
741
+ </details>
742
+
743
+
744
+ {!isDateRangeReady ? (
745
+ <div className="rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-card-bg)] p-6 text-center text-[var(--theme-text-secondary)]">
746
+ Preparing date range...
747
+ </div>
748
+ ) : eventsQuery.isLoading ? (
749
+ <div className="rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-card-bg)] p-6 text-center text-[var(--theme-text-secondary)]">
750
+ Loading events...
751
+ </div>
752
+ ) : eventsQuery.error ? (
753
+ <div className="rounded-lg border border-red-500 bg-red-500/10 p-6 text-center text-red-400">
754
+ {eventsQuery.error.message}
755
+ </div>
756
+ ) : summaryRows.length === 0 ? (
757
+ <div className="rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-card-bg)] p-6 text-center text-[var(--theme-text-secondary)]">
758
+ No events captured for this date range.
759
+ </div>
760
+ ) : (
761
+ <div className="overflow-x-auto rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-card-bg)]">
762
+ <table className="min-w-[720px] w-full divide-y divide-[var(--theme-border-primary)]">
763
+ <thead className="bg-[var(--theme-bg-secondary)]">
764
+ <tr>
765
+ <th
766
+ scope="col"
767
+ className="px-3 sm:px-6 py-3 text-left text-xs font-medium text-[var(--theme-text-secondary)] uppercase tracking-wider"
768
+ >
769
+ Event
770
+ </th>
771
+ <th
772
+ scope="col"
773
+ aria-sort={
774
+ sortBy === "count"
775
+ ? sortDirection === "asc"
776
+ ? "ascending"
777
+ : "descending"
778
+ : "none"
779
+ }
780
+ className="px-3 sm:px-6 py-3 text-left text-xs font-medium text-[var(--theme-text-secondary)] uppercase tracking-wider"
781
+ >
782
+ <button
783
+ type="button"
784
+ onClick={() => handleSort("count")}
785
+ className="inline-flex items-center gap-1 hover:text-[var(--theme-text-primary)]"
786
+ >
787
+ <span>Count</span>
788
+ <span className={sortBy === "count" ? "text-[var(--theme-text-primary)]" : "opacity-60"}>
789
+ {getSortArrow("count")}
790
+ </span>
791
+ </button>
792
+ </th>
793
+ <th
794
+ scope="col"
795
+ className="px-3 sm:px-6 py-3 text-left text-xs font-medium text-[var(--theme-text-secondary)] uppercase tracking-wider"
796
+ >
797
+ Share
798
+ </th>
799
+ <th
800
+ scope="col"
801
+ aria-sort={
802
+ sortBy === "first_seen"
803
+ ? sortDirection === "asc"
804
+ ? "ascending"
805
+ : "descending"
806
+ : "none"
807
+ }
808
+ className="px-3 sm:px-6 py-3 text-left text-xs font-medium text-[var(--theme-text-secondary)] uppercase tracking-wider"
809
+ >
810
+ <button
811
+ type="button"
812
+ onClick={() => handleSort("first_seen")}
813
+ className="inline-flex items-center gap-1 hover:text-[var(--theme-text-primary)]"
814
+ >
815
+ <span>First Seen</span>
816
+ <span className={sortBy === "first_seen" ? "text-[var(--theme-text-primary)]" : "opacity-60"}>
817
+ {getSortArrow("first_seen")}
818
+ </span>
819
+ </button>
820
+ </th>
821
+ <th
822
+ scope="col"
823
+ aria-sort={
824
+ sortBy === "last_seen"
825
+ ? sortDirection === "asc"
826
+ ? "ascending"
827
+ : "descending"
828
+ : "none"
829
+ }
830
+ className="px-3 sm:px-6 py-3 text-left text-xs font-medium text-[var(--theme-text-secondary)] uppercase tracking-wider"
831
+ >
832
+ <button
833
+ type="button"
834
+ onClick={() => handleSort("last_seen")}
835
+ className="inline-flex items-center gap-1 hover:text-[var(--theme-text-primary)]"
836
+ >
837
+ <span>Last Seen</span>
838
+ <span className={sortBy === "last_seen" ? "text-[var(--theme-text-primary)]" : "opacity-60"}>
839
+ {getSortArrow("last_seen")}
840
+ </span>
841
+ </button>
842
+ </th>
843
+ </tr>
844
+ </thead>
845
+ <tbody className="bg-[var(--theme-card-bg)] divide-y divide-[var(--theme-border-primary)]">
846
+ {summaryRows.map((row) => {
847
+ const isAuto = isAutocaptureEvent(row.event);
848
+ const isRuleCapture = isRuleCaptureEvent(row.event);
849
+ const captureMeta = getCaptureMeta(row.event);
850
+ const customLabel = row.event ? labelsMap.get(row.event) : null;
851
+ const displayName = customLabel
852
+ ? customLabel
853
+ : isAuto && row.event
854
+ ? getAutocaptureDisplayName(row.event)
855
+ : (row.event || "Unknown");
856
+
857
+ return (
858
+ <tr
859
+ key={`${row.event ?? "unknown"}-${row.firstSeen ?? ""}-${row.lastSeen ?? ""}`}
860
+ className="hover:bg-[var(--theme-bg-secondary)] transition-colors group"
861
+ >
862
+ <td className="px-3 sm:px-6 py-4 text-sm text-[var(--theme-text-primary)]">
863
+ <div className="flex items-center">
864
+ <span className={customLabel ? "font-medium" : ""}>{displayName}</span>
865
+ <EventTypeBadge isAutocapture={isAuto} eventName={row.event} />
866
+ <CaptureMethodBadge eventName={row.event} />
867
+ </div>
868
+ {/* Show original event name if there's a custom label or it's autocapture */}
869
+ {(customLabel || isAuto || isRuleCapture) && row.event && (
870
+ <div className="text-xs text-[var(--theme-text-secondary)] mt-1 font-mono">
871
+ {row.event}
872
+ </div>
873
+ )}
874
+ {captureMeta && (
875
+ <div className="text-xs text-[var(--theme-text-secondary)] mt-1">
876
+ <span className="font-medium text-[var(--theme-text-primary)]">
877
+ {captureMeta.label}
878
+ </span>
879
+ {captureMeta?.method && (
880
+ <span className="ml-1 text-[var(--theme-text-secondary)]">
881
+ · {captureMeta.method}
882
+ </span>
883
+ )}
884
+ </div>
885
+ )}
886
+ {/* Label editor - always available */}
887
+ {row.event && current_site?.id && (
888
+ <LabelEditor
889
+ eventName={row.event}
890
+ currentLabel={customLabel ?? null}
891
+ siteId={current_site.id}
892
+ onSave={handleSaveLabel}
893
+ onDelete={handleDeleteLabel}
894
+ isSaving={saveLabelMutation.isPending || deleteLabelMutation.isPending}
895
+ />
896
+ )}
897
+ </td>
898
+ <td className="px-3 sm:px-6 py-4 text-sm text-[var(--theme-text-primary)]">
899
+ {row.count.toLocaleString()}
900
+ </td>
901
+ <td className="px-3 sm:px-6 py-4 text-sm text-[var(--theme-text-primary)]">
902
+ {formatEventShare(row.share)}
903
+ </td>
904
+ <td className="px-3 sm:px-6 py-4 text-sm text-[var(--theme-text-primary)]">
905
+ {formatEventDate(row.firstSeen, effectiveTimezone)}
906
+ </td>
907
+ <td className="px-3 sm:px-6 py-4 text-sm text-[var(--theme-text-primary)]">
908
+ {formatEventDate(row.lastSeen, effectiveTimezone)}
909
+ </td>
910
+ </tr>
911
+ );
912
+ })}
913
+ </tbody>
914
+ </table>
915
+ </div>
916
+ )}
917
+
918
+ {eventSummary?.pagination && summaryRows.length > 0 && (
919
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mt-4">
920
+ <p className="text-xs text-[var(--theme-text-secondary)]">
921
+ Showing {eventSummary.pagination.offset + 1}-
922
+ {Math.min(
923
+ eventSummary.pagination.offset + eventSummary.pagination.limit,
924
+ eventSummary.pagination.total,
925
+ )} of {eventSummary.pagination.total} event types
926
+ </p>
927
+ <div className="flex items-center gap-2">
928
+ <button
929
+ type="button"
930
+ onClick={() => setCurrentPage(1)}
931
+ disabled={currentSummaryPage === 1}
932
+ className="px-3 py-1 text-xs rounded border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] disabled:opacity-50"
933
+ >
934
+ First
935
+ </button>
936
+ <button
937
+ type="button"
938
+ onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
939
+ disabled={currentSummaryPage === 1}
940
+ className="px-3 py-1 text-xs rounded border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] disabled:opacity-50"
941
+ >
942
+ Previous
943
+ </button>
944
+ <span className="text-xs text-[var(--theme-text-secondary)]">
945
+ Page {currentSummaryPage} of {totalPages}
946
+ </span>
947
+ <button
948
+ type="button"
949
+ onClick={() =>
950
+ setCurrentPage((prev) => Math.min(totalPages, prev + 1))
951
+ }
952
+ disabled={currentSummaryPage >= totalPages}
953
+ className="px-3 py-1 text-xs rounded border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] disabled:opacity-50"
954
+ >
955
+ Next
956
+ </button>
957
+ <button
958
+ type="button"
959
+ onClick={() => setCurrentPage(totalPages)}
960
+ disabled={currentSummaryPage >= totalPages}
961
+ className="px-3 py-1 text-xs rounded border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] disabled:opacity-50"
962
+ >
963
+ Last
964
+ </button>
965
+ </div>
966
+ </div>
967
+ )}
968
+ </div>
969
+ </main>
970
+ </div>
971
+ );
972
+ }
973
+
974
+ export default EventsPage;