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.
- package/.env.example +37 -0
- package/README.md +486 -0
- package/alchemy.run.ts +155 -0
- package/cli/bootstrap-admin.ts +284 -0
- package/cli/deploy-staging.ts +692 -0
- package/cli/import-events.ts +628 -0
- package/cli/import-sites.ts +518 -0
- package/cli/index.ts +609 -0
- package/cli/init-db.ts +269 -0
- package/cli/migrate-to-durable-objects.ts +564 -0
- package/cli/migration-worker.ts +300 -0
- package/cli/performance-test.ts +588 -0
- package/cli/pg/client.ts +4 -0
- package/cli/pg/new-site.ts +153 -0
- package/cli/rollback-durable-objects.ts +622 -0
- package/cli/seed-data.ts +459 -0
- package/cli/setup.js +18 -0
- package/cli/setup.ts +463 -0
- package/cli/validate-migration.ts +200 -0
- package/cli/wrangler-migration.jsonc +28 -0
- package/db/adapter.ts +166 -0
- package/db/analytics_engine/client.ts +0 -0
- package/db/analytics_engine/sites.ts +0 -0
- package/db/client.ts +16 -0
- package/db/d1/client.ts +8 -0
- package/db/d1/drizzle.config.ts +35 -0
- package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
- package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
- package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
- package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
- package/db/d1/migrations/0004_mute_stardust.sql +1 -0
- package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
- package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
- package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
- package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
- package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
- package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
- package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
- package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
- package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
- package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
- package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
- package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
- package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
- package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
- package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
- package/db/d1/migrations/meta/_journal.json +76 -0
- package/db/d1/schema.ts +407 -0
- package/db/d1/sites.ts +374 -0
- package/db/d1/teamAiUsage.ts +101 -0
- package/db/d1/teams.ts +127 -0
- package/db/durable/drizzle.config.ts +8 -0
- package/db/durable/durableObjectClient.ts +480 -0
- package/db/durable/events.ts +100 -0
- package/db/durable/migrations/0000_fair_bucky.sql +38 -0
- package/db/durable/migrations/meta/0000_snapshot.json +278 -0
- package/db/durable/migrations/meta/_journal.json +13 -0
- package/db/durable/migrations/migrations.js +10 -0
- package/db/durable/schema.ts +5 -0
- package/db/durable/siteDurableObject.ts +1352 -0
- package/db/durable/types.ts +53 -0
- package/db/postgres/client.ts +13 -0
- package/db/postgres/drizzle.config.ts +12 -0
- package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
- package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
- package/db/postgres/migrations/meta/_journal.json +13 -0
- package/db/postgres/schema.ts +145 -0
- package/db/postgres/sites.ts +118 -0
- package/db/tranformReports.ts +595 -0
- package/db/types.ts +55 -0
- package/endpoints/api_worker.tsx +1854 -0
- package/endpoints/site_do_worker.ts +11 -0
- package/index.d.ts +63 -0
- package/index.ts +83 -0
- package/lib/auth.ts +279 -0
- package/lib/geojson/world_countries.json +45307 -0
- package/lib/random_name.ts +41 -0
- package/lib/sendMail.ts +252 -0
- package/package.json +142 -0
- package/public/favicon.ico +0 -0
- package/public/images/android-chrome-192x192.png +0 -0
- package/public/images/android-chrome-512x512.png +0 -0
- package/public/images/apple-touch-icon.png +0 -0
- package/public/images/favicon-16x16.png +0 -0
- package/public/images/favicon-32x32.png +0 -0
- package/public/images/lytx_dark_dashboard.png +0 -0
- package/public/images/lytx_light_dashboard.png +0 -0
- package/public/images/safari-pinned-tab.svg +4 -0
- package/public/logo.png +0 -0
- package/public/site.webmanifest +26 -0
- package/public/sw.js +107 -0
- package/src/Document.tsx +86 -0
- package/src/api/ai_api.ts +1156 -0
- package/src/api/authMiddleware.ts +45 -0
- package/src/api/auth_api.ts +465 -0
- package/src/api/event_labels_api.ts +193 -0
- package/src/api/events_api.ts +210 -0
- package/src/api/queueWorker.ts +303 -0
- package/src/api/reports_api.ts +278 -0
- package/src/api/seed_api.ts +288 -0
- package/src/api/sites_api.ts +904 -0
- package/src/api/tag_api.ts +458 -0
- package/src/api/tag_api_v2.ts +289 -0
- package/src/api/team_api.ts +456 -0
- package/src/app/Dashboard.tsx +1339 -0
- package/src/app/Events.tsx +974 -0
- package/src/app/Explore.tsx +312 -0
- package/src/app/Layout.tsx +58 -0
- package/src/app/Settings.tsx +1302 -0
- package/src/app/components/DashboardCard.tsx +118 -0
- package/src/app/components/EditableCell.tsx +123 -0
- package/src/app/components/EventForm.tsx +93 -0
- package/src/app/components/MarketingFooter.tsx +49 -0
- package/src/app/components/MarketingNav.tsx +150 -0
- package/src/app/components/Nav.tsx +755 -0
- package/src/app/components/NewSiteSetup.tsx +298 -0
- package/src/app/components/SQLEditor.tsx +740 -0
- package/src/app/components/SiteSelector.tsx +126 -0
- package/src/app/components/SiteTag.tsx +42 -0
- package/src/app/components/SiteTagInstallCard.tsx +241 -0
- package/src/app/components/WorldMapCard.tsx +337 -0
- package/src/app/components/charts/ChartComponents.tsx +1481 -0
- package/src/app/components/charts/EventFunnel.tsx +45 -0
- package/src/app/components/charts/EventSummary.tsx +194 -0
- package/src/app/components/charts/SankeyFlows.tsx +72 -0
- package/src/app/components/marketing/CheckIcon.tsx +16 -0
- package/src/app/components/marketing/MarketingLayout.tsx +23 -0
- package/src/app/components/marketing/SectionHeading.tsx +35 -0
- package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
- package/src/app/components/reports/CreateReportStarter.tsx +74 -0
- package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
- package/src/app/components/reports/DashboardToolbar.tsx +154 -0
- package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
- package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
- package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
- package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
- package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
- package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
- package/src/app/components/reports/custom/chartPalettes.ts +18 -0
- package/src/app/components/reports/custom/types.ts +50 -0
- package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
- package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
- package/src/app/components/ui/AlertBanner.tsx +101 -0
- package/src/app/components/ui/Button.tsx +55 -0
- package/src/app/components/ui/Card.tsx +80 -0
- package/src/app/components/ui/Input.tsx +72 -0
- package/src/app/components/ui/Link.tsx +23 -0
- package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
- package/src/app/components/ui/ThemeToggle.tsx +54 -0
- package/src/app/constants.ts +6 -0
- package/src/app/headers.ts +33 -0
- package/src/app/providers/AuthProvider.tsx +189 -0
- package/src/app/providers/ClientProviders.tsx +18 -0
- package/src/app/providers/QueryProvider.tsx +23 -0
- package/src/app/providers/ThemeProvider.tsx +88 -0
- package/src/app/utils/chartThemes.ts +146 -0
- package/src/app/utils/keybinds.ts +96 -0
- package/src/app/utils/media.tsx +24 -0
- package/src/client.tsx +114 -0
- package/src/config/createLytxAppConfig.ts +252 -0
- package/src/config/resourceNames.ts +88 -0
- package/src/db/index.ts +67 -0
- package/src/index.css +285 -0
- package/src/lib/featureFlags.ts +69 -0
- package/src/pages/GetStarted.tsx +290 -0
- package/src/pages/Home.tsx +268 -0
- package/src/pages/Login.tsx +283 -0
- package/src/pages/PrivacyPolicy.tsx +120 -0
- package/src/pages/Signup.tsx +267 -0
- package/src/pages/TermsOfService.tsx +126 -0
- package/src/pages/VerifyEmail.tsx +56 -0
- package/src/session/durableObject.ts +7 -0
- package/src/session/siteSchema.ts +86 -0
- package/src/session/types.ts +36 -0
- package/src/templates/README.md +80 -0
- package/src/templates/cleanFunctions.js +44 -0
- package/src/templates/embedFunctions.js +52 -0
- package/src/templates/lytx-shared.ts +662 -0
- package/src/templates/lytxpixel-core.ts +144 -0
- package/src/templates/lytxpixel.ts +267 -0
- package/src/templates/lytxpixelBrowser.js +634 -0
- package/src/templates/lytxpixelBrowser.mjs +634 -0
- package/src/templates/parseData.js +12 -0
- package/src/templates/script.ts +31 -0
- package/src/templates/template.tsx +50 -0
- package/src/templates/test.js +3 -0
- package/src/templates/trackWebEvents.ts +177 -0
- package/src/templates/vendors/clickcease.ts +8 -0
- package/src/templates/vendors/google.ts +174 -0
- package/src/templates/vendors/linkedin.ts +23 -0
- package/src/templates/vendors/meta.ts +56 -0
- package/src/templates/vendors/quantcast.ts +22 -0
- package/src/templates/vendors/simplfi.ts +7 -0
- package/src/types/app-context.ts +16 -0
- package/src/utilities/dashboardParams.ts +188 -0
- package/src/utilities/dashboardQueries.ts +537 -0
- package/src/utilities/dashboardTransforms.ts +167 -0
- package/src/utilities/dataValidation.ts +414 -0
- package/src/utilities/detector.ts +73 -0
- package/src/utilities/encrypt.ts +103 -0
- package/src/utilities/index.ts +13 -0
- package/src/utilities/parser.ts +117 -0
- package/src/utilities/performanceMonitoring.ts +570 -0
- package/src/utilities/route_interuptors.ts +24 -0
- package/src/worker.tsx +675 -0
- package/tsconfig.json +78 -0
- package/types/env.d.ts +16 -0
- package/types/rw.d.ts +7 -0
- package/types/shims.d.ts +53 -0
- package/types/vite.d.ts +19 -0
- package/vite/vite-plugin-pixel-bundle.ts +126 -0
- package/vite.config.ts +53 -0
- 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;
|