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