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