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,1302 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useContext, useRef, useState, useEffect } from "react";
|
|
3
|
+
import { Card } from "@/app/components/ui/Card";
|
|
4
|
+
import { Button } from "@/app/components/ui/Button";
|
|
5
|
+
import { Input } from "@/app/components/ui/Input";
|
|
6
|
+
import { AlertBanner } from "@/app/components/ui/AlertBanner";
|
|
7
|
+
// import { SiteTag } from "@/app/components/SiteTag";
|
|
8
|
+
import type { GetTeamMembers, GetTeamSettings } from "@db/d1/teams";
|
|
9
|
+
import type { UserRole } from "@db/types";
|
|
10
|
+
import { SiteSelector } from "@components/SiteSelector";
|
|
11
|
+
// import {Site}
|
|
12
|
+
import { AuthContext } from "@/app/providers/AuthProvider";
|
|
13
|
+
import { SiteTagInstallCard } from "@/app/components/SiteTagInstallCard";
|
|
14
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
15
|
+
interface TeamMember {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
email: string;
|
|
19
|
+
role: string;
|
|
20
|
+
allowed_site_ids?: Array<number | "all">;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SettingsInitialSession = {
|
|
24
|
+
user?: {
|
|
25
|
+
name?: string | null;
|
|
26
|
+
email?: string | null;
|
|
27
|
+
} | null;
|
|
28
|
+
team?: {
|
|
29
|
+
id: number;
|
|
30
|
+
name?: string | null;
|
|
31
|
+
external_id?: number | null;
|
|
32
|
+
} | null;
|
|
33
|
+
role?: UserRole | null;
|
|
34
|
+
timezone?: string | null;
|
|
35
|
+
userSites?: Array<{
|
|
36
|
+
site_id: number;
|
|
37
|
+
name?: string | null;
|
|
38
|
+
domain?: string | null;
|
|
39
|
+
tag_id: string;
|
|
40
|
+
createdAt?: string | Date | null;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SettingsInitialCurrentSite = {
|
|
45
|
+
id: number;
|
|
46
|
+
name: string;
|
|
47
|
+
tag_id: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SettingsInitialSite = {
|
|
51
|
+
site_id: number;
|
|
52
|
+
name: string;
|
|
53
|
+
tag_id: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
///team/settings
|
|
57
|
+
function TeamSettings(props: {
|
|
58
|
+
team_id?: number;
|
|
59
|
+
isSessionLoading: boolean;
|
|
60
|
+
role: UserRole;
|
|
61
|
+
currentUserEmail?: string | null;
|
|
62
|
+
initialData?: Awaited<GetTeamSettings> | null;
|
|
63
|
+
onApiDataLoad?: (data: Awaited<GetTeamSettings>) => void;
|
|
64
|
+
}) {
|
|
65
|
+
const queryClient = useQueryClient();
|
|
66
|
+
const [memberSitesMessage, setMemberSitesMessage] = useAlertState();
|
|
67
|
+
const [savingMemberId, setSavingMemberId] = useState<string | null>(null);
|
|
68
|
+
const {
|
|
69
|
+
data: apiData,
|
|
70
|
+
// error: queryError,
|
|
71
|
+
isLoading,
|
|
72
|
+
// refetch: refetchData,
|
|
73
|
+
} = useQuery({
|
|
74
|
+
queryKey: ["settingPageData", props.team_id],
|
|
75
|
+
|
|
76
|
+
queryFn: async ({ queryKey }) => {
|
|
77
|
+
const [_key, _dataFilters] = queryKey;
|
|
78
|
+
const response = await fetch("/api/team/settings", {
|
|
79
|
+
method: "GET",
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error("Failed to fetch dashboard data");
|
|
85
|
+
}
|
|
86
|
+
// console.log("We fetched the data", session);
|
|
87
|
+
|
|
88
|
+
return response.json() as GetTeamSettings;
|
|
89
|
+
},
|
|
90
|
+
enabled: !props.isSessionLoading && !!props.team_id,
|
|
91
|
+
initialData: props.initialData ?? undefined,
|
|
92
|
+
staleTime: 0,
|
|
93
|
+
gcTime: 0,
|
|
94
|
+
});
|
|
95
|
+
const [memberRoles, setMemberRoles] = useState<Record<string, UserRole>>({});
|
|
96
|
+
const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
|
|
97
|
+
|
|
98
|
+
// Pass team data to parent when available
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (apiData && props.onApiDataLoad) {
|
|
101
|
+
props.onApiDataLoad(apiData);
|
|
102
|
+
}
|
|
103
|
+
}, [apiData, props]);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!apiData) return;
|
|
107
|
+
|
|
108
|
+
const nextRoles: Record<string, UserRole> = {};
|
|
109
|
+
for (const member of apiData.members) {
|
|
110
|
+
nextRoles[member.id] = member.role as UserRole;
|
|
111
|
+
}
|
|
112
|
+
setMemberRoles(nextRoles);
|
|
113
|
+
}, [apiData]);
|
|
114
|
+
|
|
115
|
+
const saveMemberRole = async (memberId: string, originalRole: UserRole) => {
|
|
116
|
+
const selectedRole = memberRoles[memberId] ?? originalRole;
|
|
117
|
+
if (selectedRole === originalRole) {
|
|
118
|
+
setMemberSitesMessage({
|
|
119
|
+
type: "info",
|
|
120
|
+
text: "No role changes to save.",
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setSavingMemberId(memberId);
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch("/api/team/update-member-role", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
user_id: memberId,
|
|
132
|
+
role: selectedRole,
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
const text = await response.text();
|
|
138
|
+
setMemberSitesMessage({
|
|
139
|
+
type: "error",
|
|
140
|
+
text: `Error updating member role: ${text}`,
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const updated = (await response.json()) as TeamMember;
|
|
146
|
+
const nextRole = (updated.role as UserRole) ?? selectedRole;
|
|
147
|
+
setMemberRoles((prev) => ({
|
|
148
|
+
...prev,
|
|
149
|
+
[memberId]: nextRole,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
if (apiData && props.onApiDataLoad) {
|
|
153
|
+
props.onApiDataLoad({
|
|
154
|
+
...apiData,
|
|
155
|
+
members: apiData.members.map((member) =>
|
|
156
|
+
member.id === memberId
|
|
157
|
+
? { ...member, role: nextRole }
|
|
158
|
+
: member,
|
|
159
|
+
),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await queryClient.invalidateQueries({
|
|
164
|
+
queryKey: ["settingPageData", props.team_id],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
setMemberSitesMessage({
|
|
168
|
+
type: "success",
|
|
169
|
+
text: "Member role updated.",
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error("Error updating member role:", error);
|
|
173
|
+
setMemberSitesMessage({
|
|
174
|
+
type: "error",
|
|
175
|
+
text: "Error updating member role.",
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
setSavingMemberId(null);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (isLoading || !apiData) {
|
|
183
|
+
return (
|
|
184
|
+
<div className="py-8 text-center text-[var(--theme-text-secondary)]">
|
|
185
|
+
Loading team settings...
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const normalizedCurrentUserEmail = props.currentUserEmail?.trim().toLowerCase() ?? null;
|
|
191
|
+
const orderedMembers = [...apiData.members].toSorted((left, right) => {
|
|
192
|
+
const leftIsCurrent =
|
|
193
|
+
normalizedCurrentUserEmail !== null
|
|
194
|
+
&& left.email?.toLowerCase() === normalizedCurrentUserEmail;
|
|
195
|
+
const rightIsCurrent =
|
|
196
|
+
normalizedCurrentUserEmail !== null
|
|
197
|
+
&& right.email?.toLowerCase() === normalizedCurrentUserEmail;
|
|
198
|
+
|
|
199
|
+
if (leftIsCurrent === rightIsCurrent) return 0;
|
|
200
|
+
return leftIsCurrent ? -1 : 1;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className="space-y-8">
|
|
205
|
+
{/* Members List */}
|
|
206
|
+
<div className="space-y-3">
|
|
207
|
+
<h3 className="text-sm font-medium uppercase tracking-wider text-[var(--theme-text-secondary)]">
|
|
208
|
+
Team Members
|
|
209
|
+
</h3>
|
|
210
|
+
{memberSitesMessage ? (
|
|
211
|
+
<AlertBanner
|
|
212
|
+
tone={memberSitesMessage.type}
|
|
213
|
+
message={memberSitesMessage.text}
|
|
214
|
+
onDismiss={() => setMemberSitesMessage(null)}
|
|
215
|
+
/>
|
|
216
|
+
) : null}
|
|
217
|
+
<div className="border border-[var(--theme-border-primary)] rounded-lg divide-y divide-[var(--theme-border-primary)] overflow-hidden bg-[var(--theme-bg-secondary)]/30">
|
|
218
|
+
{orderedMembers.map((member) => {
|
|
219
|
+
const isCurrentUser =
|
|
220
|
+
normalizedCurrentUserEmail !== null
|
|
221
|
+
&& member.email?.toLowerCase() === normalizedCurrentUserEmail;
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div
|
|
225
|
+
key={member.id}
|
|
226
|
+
className={`p-4 flex flex-col sm:flex-row sm:items-start gap-4 transition-colors ${
|
|
227
|
+
isCurrentUser
|
|
228
|
+
? "bg-amber-500/5"
|
|
229
|
+
: "hover:bg-[var(--theme-bg-secondary)]/50"
|
|
230
|
+
}`}
|
|
231
|
+
>
|
|
232
|
+
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
233
|
+
<div className="space-y-3">
|
|
234
|
+
{isCurrentUser ? (
|
|
235
|
+
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-amber-400">
|
|
236
|
+
Your account
|
|
237
|
+
</div>
|
|
238
|
+
) : null}
|
|
239
|
+
<Input
|
|
240
|
+
disabled
|
|
241
|
+
label="Name"
|
|
242
|
+
type="text"
|
|
243
|
+
value={member.name!}
|
|
244
|
+
placeholder="Member name"
|
|
245
|
+
/>
|
|
246
|
+
<Input
|
|
247
|
+
disabled
|
|
248
|
+
label="Email"
|
|
249
|
+
type="email"
|
|
250
|
+
value={member.email!}
|
|
251
|
+
placeholder="Member email"
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div className="space-y-3">
|
|
256
|
+
<div>
|
|
257
|
+
<label className="block text-sm font-medium text-[var(--theme-text-primary)] mb-2">
|
|
258
|
+
Role
|
|
259
|
+
</label>
|
|
260
|
+
<select
|
|
261
|
+
disabled={props.role !== "admin"}
|
|
262
|
+
value={memberRoles[member.id] ?? (member.role as UserRole)}
|
|
263
|
+
onChange={(e) => {
|
|
264
|
+
setMemberRoles((prev) => ({
|
|
265
|
+
...prev,
|
|
266
|
+
[member.id]: e.target.value as UserRole,
|
|
267
|
+
}));
|
|
268
|
+
}}
|
|
269
|
+
className="w-full px-4 py-2 bg-[var(--theme-input-bg)] border border-[var(--theme-input-border)] rounded-lg text-[var(--theme-text-primary)] focus:border-[var(--theme-border-primary)] focus:outline-none transition-colors"
|
|
270
|
+
>
|
|
271
|
+
<option value="admin">Admin</option>
|
|
272
|
+
<option value="editor">Editor</option>
|
|
273
|
+
<option value="viewer">Viewer</option>
|
|
274
|
+
</select>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div>
|
|
278
|
+
<label className="block text-sm font-medium text-[var(--theme-text-primary)] mb-2">
|
|
279
|
+
Allowed Sites
|
|
280
|
+
</label>
|
|
281
|
+
<select
|
|
282
|
+
multiple
|
|
283
|
+
disabled={props.role !== "admin"}
|
|
284
|
+
value={
|
|
285
|
+
member.allowed_site_ids &&
|
|
286
|
+
member.allowed_site_ids.length > 0
|
|
287
|
+
? member.allowed_site_ids.map(String)
|
|
288
|
+
: ["all"]
|
|
289
|
+
}
|
|
290
|
+
onChange={async (e) => {
|
|
291
|
+
let values = Array.from(
|
|
292
|
+
e.target.selectedOptions,
|
|
293
|
+
(option) => option.value,
|
|
294
|
+
);
|
|
295
|
+
if (values.includes("all") && values.length > 1) {
|
|
296
|
+
values = values.filter((value) => value !== "all");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const allowed_site_ids = values.includes("all")
|
|
300
|
+
? (["all"] as Array<number | "all">)
|
|
301
|
+
: values
|
|
302
|
+
.map((value) => parseInt(value, 10))
|
|
303
|
+
.filter((value) => !Number.isNaN(value));
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const response = await fetch(
|
|
307
|
+
"/api/team/update-member-sites",
|
|
308
|
+
{
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "Content-Type": "application/json" },
|
|
311
|
+
body: JSON.stringify({
|
|
312
|
+
user_id: member.id,
|
|
313
|
+
allowed_site_ids,
|
|
314
|
+
}),
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
const text = await response.text();
|
|
320
|
+
setMemberSitesMessage({
|
|
321
|
+
type: "error",
|
|
322
|
+
text: `Error updating member sites: ${text}`,
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const updated = (await response.json()) as TeamMember;
|
|
328
|
+
if (apiData && props.onApiDataLoad) {
|
|
329
|
+
props.onApiDataLoad({
|
|
330
|
+
...apiData,
|
|
331
|
+
members: apiData.members.map((m) =>
|
|
332
|
+
m.id === member.id
|
|
333
|
+
? {
|
|
334
|
+
...m,
|
|
335
|
+
allowed_site_ids:
|
|
336
|
+
updated.allowed_site_ids ?? ["all"],
|
|
337
|
+
}
|
|
338
|
+
: m,
|
|
339
|
+
),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
setMemberSitesMessage({
|
|
343
|
+
type: "success",
|
|
344
|
+
text: "Member site access updated.",
|
|
345
|
+
});
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.error("Error updating member sites:", error);
|
|
348
|
+
setMemberSitesMessage({
|
|
349
|
+
type: "error",
|
|
350
|
+
text: "Error updating member sites.",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}}
|
|
354
|
+
className="w-full px-4 py-2 bg-[var(--theme-input-bg)] border border-[var(--theme-input-border)] rounded-lg text-[var(--theme-text-primary)] focus:border-[var(--theme-border-primary)] focus:outline-none min-h-[100px] transition-colors"
|
|
355
|
+
>
|
|
356
|
+
<option value="all">All sites</option>
|
|
357
|
+
{apiData.sites?.map((site) => (
|
|
358
|
+
<option
|
|
359
|
+
key={site.site_id}
|
|
360
|
+
value={site.site_id.toString()}
|
|
361
|
+
>
|
|
362
|
+
{site.name || site.domain || `Site ${site.site_id}`}
|
|
363
|
+
</option>
|
|
364
|
+
))}
|
|
365
|
+
</select>
|
|
366
|
+
<p className="text-xs text-[var(--theme-text-secondary)] mt-1.5">
|
|
367
|
+
Hold Ctrl/Cmd to select multiple sites
|
|
368
|
+
</p>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{props.role === "admin" && (
|
|
374
|
+
<div className="flex sm:flex-col gap-2 pt-1 sm:pt-7">
|
|
375
|
+
<Button
|
|
376
|
+
variant="primary"
|
|
377
|
+
size="sm"
|
|
378
|
+
onClick={() => {
|
|
379
|
+
void saveMemberRole(member.id, member.role as UserRole);
|
|
380
|
+
}}
|
|
381
|
+
disabled={savingMemberId === member.id}
|
|
382
|
+
>
|
|
383
|
+
{savingMemberId === member.id ? "Saving..." : "Save"}
|
|
384
|
+
</Button>
|
|
385
|
+
<Button
|
|
386
|
+
variant="danger"
|
|
387
|
+
size="sm"
|
|
388
|
+
disabled={isCurrentUser}
|
|
389
|
+
title={isCurrentUser ? "You cannot remove your own account from this view." : undefined}
|
|
390
|
+
>
|
|
391
|
+
Remove
|
|
392
|
+
</Button>
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
})}
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
{/* API Keys List */}
|
|
402
|
+
<div className="space-y-3">
|
|
403
|
+
<h3 className="text-sm font-medium uppercase tracking-wider text-[var(--theme-text-secondary)]">
|
|
404
|
+
API Keys
|
|
405
|
+
</h3>
|
|
406
|
+
<div className="border border-[var(--theme-border-primary)] rounded-lg divide-y divide-[var(--theme-border-primary)] overflow-hidden bg-[var(--theme-bg-secondary)]/30">
|
|
407
|
+
{apiData.keys.map((key) => (
|
|
408
|
+
<div
|
|
409
|
+
key={key.id}
|
|
410
|
+
className="p-4 flex items-center justify-between hover:bg-[var(--theme-bg-secondary)]/50 transition-colors"
|
|
411
|
+
>
|
|
412
|
+
<div className="space-y-1">
|
|
413
|
+
{(() => {
|
|
414
|
+
const linkedSite =
|
|
415
|
+
typeof key.site_id === "number"
|
|
416
|
+
? apiData.sites?.find((site) => site.site_id === key.site_id)
|
|
417
|
+
: null;
|
|
418
|
+
const siteLabel = linkedSite
|
|
419
|
+
? linkedSite.name || linkedSite.domain || `Site ${linkedSite.site_id}`
|
|
420
|
+
: typeof key.site_id === "number"
|
|
421
|
+
? `Site ${key.site_id}`
|
|
422
|
+
: "No site assigned";
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<div className="text-xs text-[var(--theme-text-secondary)]">
|
|
426
|
+
<span className="font-medium">Site:</span> {siteLabel}
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
})()}
|
|
430
|
+
<div className="flex items-center gap-3">
|
|
431
|
+
<code className="px-2 py-1 bg-[var(--theme-input-bg)] border border-[var(--theme-border-primary)] rounded text-sm font-mono text-[var(--theme-text-primary)]">
|
|
432
|
+
{key.key}
|
|
433
|
+
</code>
|
|
434
|
+
<div className="flex items-center gap-2">
|
|
435
|
+
<button
|
|
436
|
+
onClick={() => {
|
|
437
|
+
if (key.key) {
|
|
438
|
+
navigator.clipboard.writeText(key.key);
|
|
439
|
+
setCopiedKeyId(key.id);
|
|
440
|
+
setTimeout(() => setCopiedKeyId(null), 2000);
|
|
441
|
+
}
|
|
442
|
+
}}
|
|
443
|
+
className={`p-1.5 rounded-md transition-all duration-200 ${copiedKeyId === key.id
|
|
444
|
+
? "bg-green-100 text-green-600"
|
|
445
|
+
: "text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)] hover:bg-[var(--theme-bg-secondary)]"
|
|
446
|
+
}`}
|
|
447
|
+
title="Copy API key"
|
|
448
|
+
>
|
|
449
|
+
{copiedKeyId === key.id ? (
|
|
450
|
+
<svg
|
|
451
|
+
width="16"
|
|
452
|
+
height="16"
|
|
453
|
+
viewBox="0 0 24 24"
|
|
454
|
+
fill="none"
|
|
455
|
+
stroke="currentColor"
|
|
456
|
+
strokeWidth="2"
|
|
457
|
+
strokeLinecap="round"
|
|
458
|
+
strokeLinejoin="round"
|
|
459
|
+
>
|
|
460
|
+
<path d="M20 6L9 17l-5-5" />
|
|
461
|
+
</svg>
|
|
462
|
+
) : (
|
|
463
|
+
<svg
|
|
464
|
+
width="16"
|
|
465
|
+
height="16"
|
|
466
|
+
viewBox="0 0 24 24"
|
|
467
|
+
fill="none"
|
|
468
|
+
stroke="currentColor"
|
|
469
|
+
strokeWidth="2"
|
|
470
|
+
strokeLinecap="round"
|
|
471
|
+
strokeLinejoin="round"
|
|
472
|
+
>
|
|
473
|
+
<rect
|
|
474
|
+
width="14"
|
|
475
|
+
height="14"
|
|
476
|
+
x="8"
|
|
477
|
+
y="8"
|
|
478
|
+
rx="2"
|
|
479
|
+
ry="2"
|
|
480
|
+
/>
|
|
481
|
+
<path d="m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
482
|
+
</svg>
|
|
483
|
+
)}
|
|
484
|
+
</button>
|
|
485
|
+
{copiedKeyId === key.id && (
|
|
486
|
+
<span className="text-xs text-green-600 font-medium animate-fade-in">
|
|
487
|
+
Copied
|
|
488
|
+
</span>
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
<span className="inline-flex px-2 py-0.5 text-xs font-semibold rounded-full bg-emerald-500/10 text-emerald-600 border border-emerald-500/20">
|
|
492
|
+
Active
|
|
493
|
+
</span>
|
|
494
|
+
</div>
|
|
495
|
+
<div className="text-sm text-[var(--theme-text-secondary)]">
|
|
496
|
+
<span className="font-medium">Permissions:</span>{" "}
|
|
497
|
+
{key.permissions.read && !key.permissions.write
|
|
498
|
+
? "Read Only"
|
|
499
|
+
: "Read & Write"}
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
))}
|
|
504
|
+
{apiData.keys.length === 0 && (
|
|
505
|
+
<div className="p-8 text-center text-[var(--theme-text-secondary)]">
|
|
506
|
+
No API keys created yet.
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function useAutoDismiss<T>(
|
|
516
|
+
value: T | null,
|
|
517
|
+
setValue: (next: T | null) => void,
|
|
518
|
+
delay = 5000,
|
|
519
|
+
) {
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
if (!value) return;
|
|
522
|
+
const handle = window.setTimeout(() => {
|
|
523
|
+
setValue(null);
|
|
524
|
+
}, delay);
|
|
525
|
+
|
|
526
|
+
return () => {
|
|
527
|
+
window.clearTimeout(handle);
|
|
528
|
+
};
|
|
529
|
+
}, [value, delay, setValue]);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function useAlertState() {
|
|
533
|
+
const [alert, setAlert] = useState<{ type: "success" | "error" | "info"; text: string } | null>(null);
|
|
534
|
+
useAutoDismiss(alert, setAlert);
|
|
535
|
+
return [alert, setAlert] as const;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
type SettingsPageProps = {
|
|
539
|
+
initialSession?: SettingsInitialSession | null;
|
|
540
|
+
initialCurrentSite?: SettingsInitialCurrentSite | null;
|
|
541
|
+
initialSites?: SettingsInitialSite[];
|
|
542
|
+
initialTeamSettings?: Awaited<GetTeamSettings> | null;
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
export function SettingsPage({
|
|
546
|
+
initialSession = null,
|
|
547
|
+
initialCurrentSite = null,
|
|
548
|
+
initialSites = [],
|
|
549
|
+
initialTeamSettings = null,
|
|
550
|
+
}: SettingsPageProps) {
|
|
551
|
+
const auth = useContext(AuthContext) || null;
|
|
552
|
+
const authSession = auth?.data ?? null;
|
|
553
|
+
const session = authSession ?? initialSession;
|
|
554
|
+
const currentUserEmail =
|
|
555
|
+
((authSession as any)?.user?.email as string | undefined)
|
|
556
|
+
?? ((authSession as any)?.email as string | undefined)
|
|
557
|
+
?? initialSession?.user?.email
|
|
558
|
+
?? null;
|
|
559
|
+
const sessionTeamName = authSession?.team?.name ?? initialSession?.team?.name ?? null;
|
|
560
|
+
const sessionTimezone = authSession?.timezone ?? initialSession?.timezone ?? null;
|
|
561
|
+
const isSessionLoading = (auth?.isPending ?? false) && !initialSession;
|
|
562
|
+
const current_site = auth?.current_site ?? initialCurrentSite;
|
|
563
|
+
const refetch = auth?.refetch ?? (async () => undefined);
|
|
564
|
+
const initialSiteId = initialCurrentSite?.id ?? initialSites[0]?.site_id ?? null;
|
|
565
|
+
const [teamName, setTeamName] = useState(() => sessionTeamName ?? "");
|
|
566
|
+
const [teamNameMessage, setTeamNameMessage] = useAlertState();
|
|
567
|
+
const [apiKeyMessage, setApiKeyMessage] = useAlertState();
|
|
568
|
+
const [memberMessage, setMemberMessage] = useAlertState();
|
|
569
|
+
const [siteMessage, setSiteMessage] = useAlertState();
|
|
570
|
+
// Add member form state
|
|
571
|
+
const [showAddMemberForm, setShowAddMemberForm] = useState(false);
|
|
572
|
+
const [newMemberData, setNewMemberData] = useState({
|
|
573
|
+
email: "",
|
|
574
|
+
name: "",
|
|
575
|
+
role: "editor" as UserRole,
|
|
576
|
+
});
|
|
577
|
+
const [isAddingMember, setIsAddingMember] = useState(false);
|
|
578
|
+
const [showAddApiKeyForm, setShowAddApiKeyForm] = useState(false);
|
|
579
|
+
const [newApiKeyData, setNewApiKeyData] = useState({
|
|
580
|
+
permissions: { read: true, write: false },
|
|
581
|
+
allowed_team_members: ["all"] as string[]
|
|
582
|
+
});
|
|
583
|
+
const [isAddingApiKey, setIsAddingApiKey] = useState(false);
|
|
584
|
+
const [teamMembersData, setTeamMembersData] = useState<Awaited<GetTeamSettings> | null>(initialTeamSettings);
|
|
585
|
+
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
if (!isSessionLoading && sessionTeamName) {
|
|
588
|
+
setTeamName(sessionTeamName);
|
|
589
|
+
} else if (!isSessionLoading) {
|
|
590
|
+
setTeamName("");
|
|
591
|
+
}
|
|
592
|
+
}, [isSessionLoading, session?.team?.id, sessionTeamName]);
|
|
593
|
+
|
|
594
|
+
// Add site form state
|
|
595
|
+
const [showAddSiteForm, setShowAddSiteForm] = useState(false);
|
|
596
|
+
const [newSiteData, setNewSiteData] = useState({
|
|
597
|
+
name: "",
|
|
598
|
+
domain: "",
|
|
599
|
+
track_web_events: true,
|
|
600
|
+
gdpr: false,
|
|
601
|
+
autocapture: true,
|
|
602
|
+
event_load_strategy: "sdk" as "sdk" | "kv",
|
|
603
|
+
});
|
|
604
|
+
const [isAddingSite, setIsAddingSite] = useState(false);
|
|
605
|
+
|
|
606
|
+
// User timezone state
|
|
607
|
+
const [userTimezone, setUserTimezone] = useState<string>(() => sessionTimezone ?? "");
|
|
608
|
+
const [isUpdatingTimezone, setIsUpdatingTimezone] = useState(false);
|
|
609
|
+
const [timezoneMessage, setTimezoneMessage] = useAlertState();
|
|
610
|
+
|
|
611
|
+
// Initialize timezone from session, or default to browser timezone for display
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
if (isSessionLoading) return;
|
|
614
|
+
|
|
615
|
+
if (sessionTimezone) {
|
|
616
|
+
setUserTimezone(sessionTimezone);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Pre-fill with browser timezone as a suggested default
|
|
621
|
+
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
622
|
+
setUserTimezone(browserTimezone);
|
|
623
|
+
}, [isSessionLoading, sessionTimezone]);
|
|
624
|
+
|
|
625
|
+
async function handleUpdateTimezone() {
|
|
626
|
+
if (!userTimezone) return;
|
|
627
|
+
|
|
628
|
+
setIsUpdatingTimezone(true);
|
|
629
|
+
setTimezoneMessage(null);
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const response = await fetch("/api/user/update-timezone", {
|
|
633
|
+
method: "POST",
|
|
634
|
+
headers: { "Content-Type": "application/json" },
|
|
635
|
+
body: JSON.stringify({ timezone: userTimezone }),
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
if (response.ok) {
|
|
639
|
+
setTimezoneMessage({ type: "success", text: "Timezone updated successfully" });
|
|
640
|
+
// Refetch session to get updated timezone
|
|
641
|
+
refetch();
|
|
642
|
+
} else {
|
|
643
|
+
const data = await response.json().catch(() => ({})) as { error?: string };
|
|
644
|
+
setTimezoneMessage({ type: "error", text: data.error || "Failed to update timezone" });
|
|
645
|
+
}
|
|
646
|
+
} catch (error) {
|
|
647
|
+
console.error("Error updating timezone:", error);
|
|
648
|
+
setTimezoneMessage({ type: "error", text: "Failed to update timezone" });
|
|
649
|
+
} finally {
|
|
650
|
+
setIsUpdatingTimezone(false);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
const currentTeamName = (!isSessionLoading && session?.team?.name) || "";
|
|
656
|
+
async function updateTeamName(
|
|
657
|
+
_e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
658
|
+
) {
|
|
659
|
+
const nextName = teamName.trim();
|
|
660
|
+
if (!nextName) return;
|
|
661
|
+
if (import.meta.env.DEV) console.log(currentTeamName);
|
|
662
|
+
if (currentTeamName === nextName) return;
|
|
663
|
+
if (import.meta.env.DEV) console.log(nextName, currentTeamName);
|
|
664
|
+
setTeamNameMessage(null);
|
|
665
|
+
|
|
666
|
+
const response = await fetch("/api/team/update", {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: {
|
|
669
|
+
"Content-Type": "application/json",
|
|
670
|
+
},
|
|
671
|
+
body: JSON.stringify({
|
|
672
|
+
option: "name",
|
|
673
|
+
name: nextName,
|
|
674
|
+
}),
|
|
675
|
+
});
|
|
676
|
+
if (response.ok) {
|
|
677
|
+
setTeamNameMessage({ type: "success", text: "Team name updated." });
|
|
678
|
+
setTeamName(nextName);
|
|
679
|
+
refetch();
|
|
680
|
+
} else {
|
|
681
|
+
setTeamNameMessage({ type: "error", text: "Error updating team name." });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function handleAddApiKey() {
|
|
686
|
+
if (!newApiKeyData.permissions.read && !newApiKeyData.permissions.write) {
|
|
687
|
+
setApiKeyMessage({ type: "error", text: "Please select at least one permission." });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (!current_site?.id) {
|
|
692
|
+
setApiKeyMessage({ type: "error", text: "Please select a site before creating an API key." });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
setIsAddingApiKey(true);
|
|
697
|
+
try {
|
|
698
|
+
|
|
699
|
+
const response = await fetch("/api/team/add-api-key", {
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: {
|
|
702
|
+
"Content-Type": "application/json",
|
|
703
|
+
},
|
|
704
|
+
body: JSON.stringify({
|
|
705
|
+
site_id: current_site.id,
|
|
706
|
+
permissions: newApiKeyData.permissions,
|
|
707
|
+
allowed_team_members: newApiKeyData.allowed_team_members,
|
|
708
|
+
}),
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
if (response.ok) {
|
|
712
|
+
setApiKeyMessage({ type: "success", text: "Team API key added." });
|
|
713
|
+
setNewApiKeyData({ permissions: { read: true, write: false }, allowed_team_members: ["all"] as string[] });
|
|
714
|
+
setShowAddApiKeyForm(false);
|
|
715
|
+
refetch();
|
|
716
|
+
} else {
|
|
717
|
+
const errorText = await response.text();
|
|
718
|
+
setApiKeyMessage({ type: "error", text: `Error adding team API key: ${errorText}` });
|
|
719
|
+
}
|
|
720
|
+
} catch (error) {
|
|
721
|
+
console.error("Error adding team API key:", error);
|
|
722
|
+
setApiKeyMessage({ type: "error", text: "Error adding team API key." });
|
|
723
|
+
} finally {
|
|
724
|
+
setIsAddingApiKey(false);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function handleAddMember() {
|
|
729
|
+
if (!newMemberData.email.trim() || !newMemberData.name.trim()) {
|
|
730
|
+
setMemberMessage({ type: "error", text: "Please enter both name and email address." });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
setIsAddingMember(true);
|
|
735
|
+
try {
|
|
736
|
+
const response = await fetch("/api/team/add-member", {
|
|
737
|
+
method: "POST",
|
|
738
|
+
headers: {
|
|
739
|
+
"Content-Type": "application/json",
|
|
740
|
+
},
|
|
741
|
+
body: JSON.stringify({
|
|
742
|
+
email: newMemberData.email,
|
|
743
|
+
name: newMemberData.name,
|
|
744
|
+
role: newMemberData.role,
|
|
745
|
+
}),
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
if (response.ok) {
|
|
749
|
+
setMemberMessage({ type: "success", text: "Team member added." });
|
|
750
|
+
setNewMemberData({ email: "", name: "", role: "editor" as UserRole });
|
|
751
|
+
setShowAddMemberForm(false);
|
|
752
|
+
refetch();
|
|
753
|
+
} else {
|
|
754
|
+
const errorText = await response.text();
|
|
755
|
+
setMemberMessage({ type: "error", text: `Error adding team member: ${errorText}` });
|
|
756
|
+
}
|
|
757
|
+
} catch (error) {
|
|
758
|
+
console.error("Error adding team member:", error);
|
|
759
|
+
setMemberMessage({ type: "error", text: "Error adding team member." });
|
|
760
|
+
} finally {
|
|
761
|
+
setIsAddingMember(false);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function handleAddSite() {
|
|
766
|
+
if (!newSiteData.name.trim() || !newSiteData.domain.trim()) {
|
|
767
|
+
setSiteMessage({ type: "error", text: "Please enter both site name and domain." });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
setIsAddingSite(true);
|
|
772
|
+
try {
|
|
773
|
+
const response = await fetch("/api/sites", {
|
|
774
|
+
method: "POST",
|
|
775
|
+
headers: {
|
|
776
|
+
"Content-Type": "application/json",
|
|
777
|
+
},
|
|
778
|
+
body: JSON.stringify(newSiteData),
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
if (response.ok) {
|
|
782
|
+
// const newSite = await response.json();
|
|
783
|
+
setSiteMessage({ type: "success", text: "Site added." });
|
|
784
|
+
setNewSiteData({
|
|
785
|
+
name: "",
|
|
786
|
+
domain: "",
|
|
787
|
+
track_web_events: true,
|
|
788
|
+
gdpr: false,
|
|
789
|
+
autocapture: true,
|
|
790
|
+
event_load_strategy: "sdk",
|
|
791
|
+
});
|
|
792
|
+
setShowAddSiteForm(false);
|
|
793
|
+
refetch();
|
|
794
|
+
} else {
|
|
795
|
+
const errorText = await response.text();
|
|
796
|
+
setSiteMessage({ type: "error", text: `Error adding site: ${errorText}` });
|
|
797
|
+
}
|
|
798
|
+
} catch (error) {
|
|
799
|
+
console.error("Error adding site:", error);
|
|
800
|
+
setSiteMessage({ type: "error", text: "Error adding site." });
|
|
801
|
+
} finally {
|
|
802
|
+
setIsAddingSite(false);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Get current site tag data
|
|
807
|
+
const currentSiteTag =
|
|
808
|
+
!isSessionLoading && session && current_site && session.userSites
|
|
809
|
+
? session.userSites.find((site) => site.site_id === current_site.id)
|
|
810
|
+
: null;
|
|
811
|
+
return (
|
|
812
|
+
<div className="bg-[var(--theme-bg-primary)] min-h-screen px-4 py-4 sm:p-6">
|
|
813
|
+
<div className="max-w-4xl mx-auto space-y-6">
|
|
814
|
+
<h1 className="text-3xl font-bold text-[var(--theme-text-primary)] mb-8">
|
|
815
|
+
Settings
|
|
816
|
+
</h1>
|
|
817
|
+
{/* User Profile Section */}
|
|
818
|
+
<Card className="p-4 sm:p-6">
|
|
819
|
+
<h2 className="text-xl font-semibold text-[var(--theme-text-primary)] mb-4">
|
|
820
|
+
Your Profile
|
|
821
|
+
</h2>
|
|
822
|
+
<div className="space-y-4">
|
|
823
|
+
{/* User Info */}
|
|
824
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
825
|
+
<Input
|
|
826
|
+
type="text"
|
|
827
|
+
disabled
|
|
828
|
+
value={session?.user?.name || ""}
|
|
829
|
+
label="Name"
|
|
830
|
+
/>
|
|
831
|
+
<Input
|
|
832
|
+
type="email"
|
|
833
|
+
disabled
|
|
834
|
+
value={session?.user?.email || ""}
|
|
835
|
+
label="Email"
|
|
836
|
+
/>
|
|
837
|
+
</div>
|
|
838
|
+
|
|
839
|
+
{/* Timezone Selector */}
|
|
840
|
+
<div>
|
|
841
|
+
<label className="block text-sm font-medium text-[var(--theme-text-primary)] mb-2">
|
|
842
|
+
Default Timezone
|
|
843
|
+
</label>
|
|
844
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
|
845
|
+
<select
|
|
846
|
+
value={userTimezone}
|
|
847
|
+
onChange={(e) => setUserTimezone(e.target.value)}
|
|
848
|
+
className="w-full min-w-0 px-4 py-2 bg-[var(--theme-input-bg)] border border-[var(--theme-input-border)] rounded-lg text-[var(--theme-text-primary)] focus:border-[var(--theme-border-primary)] focus:outline-none transition-colors sm:flex-1"
|
|
849
|
+
>
|
|
850
|
+
<option value="">Select timezone...</option>
|
|
851
|
+
{Intl.supportedValuesOf("timeZone").map((tz) => (
|
|
852
|
+
<option key={tz} value={tz}>
|
|
853
|
+
{tz.replace(/_/g, " ")}
|
|
854
|
+
</option>
|
|
855
|
+
))}
|
|
856
|
+
</select>
|
|
857
|
+
<Button
|
|
858
|
+
onClick={handleUpdateTimezone}
|
|
859
|
+
variant="primary"
|
|
860
|
+
disabled={isUpdatingTimezone || !userTimezone}
|
|
861
|
+
className="w-full sm:w-auto sm:shrink-0"
|
|
862
|
+
>
|
|
863
|
+
{isUpdatingTimezone ? "Saving..." : "Save"}
|
|
864
|
+
</Button>
|
|
865
|
+
</div>
|
|
866
|
+
<p className="text-xs text-[var(--theme-text-secondary)] mt-1">
|
|
867
|
+
This timezone will be used for displaying dates and times in the dashboard.
|
|
868
|
+
</p>
|
|
869
|
+
{timezoneMessage && (
|
|
870
|
+
<p
|
|
871
|
+
className={`text-sm mt-2 ${timezoneMessage.type === "success"
|
|
872
|
+
? "text-green-600"
|
|
873
|
+
: "text-red-500"
|
|
874
|
+
}`}
|
|
875
|
+
>
|
|
876
|
+
{timezoneMessage.text}
|
|
877
|
+
</p>
|
|
878
|
+
)}
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
</Card>
|
|
882
|
+
|
|
883
|
+
{/* Team Name Section */}
|
|
884
|
+
<Card className="p-4 sm:p-6">
|
|
885
|
+
<h2 className="text-xl font-semibold text-[var(--theme-text-primary)] mb-4">
|
|
886
|
+
Team Name
|
|
887
|
+
</h2>
|
|
888
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
|
889
|
+
<Input
|
|
890
|
+
type="text"
|
|
891
|
+
disabled={session?.role === "admin" ? false : true}
|
|
892
|
+
value={teamName}
|
|
893
|
+
onChange={(event) => setTeamName(event.target.value)}
|
|
894
|
+
className="w-full sm:flex-1"
|
|
895
|
+
placeholder="Enter team name"
|
|
896
|
+
/>
|
|
897
|
+
{session?.role === "admin" ? (
|
|
898
|
+
<Button
|
|
899
|
+
onClick={async (e) => await updateTeamName(e)}
|
|
900
|
+
variant="primary"
|
|
901
|
+
className="w-full sm:w-auto"
|
|
902
|
+
>
|
|
903
|
+
Save
|
|
904
|
+
</Button>
|
|
905
|
+
) : null}
|
|
906
|
+
</div>
|
|
907
|
+
{teamNameMessage ? (
|
|
908
|
+
<div className="mt-3">
|
|
909
|
+
<AlertBanner
|
|
910
|
+
tone={teamNameMessage.type}
|
|
911
|
+
message={teamNameMessage.text}
|
|
912
|
+
onDismiss={() => setTeamNameMessage(null)}
|
|
913
|
+
/>
|
|
914
|
+
</div>
|
|
915
|
+
) : null}
|
|
916
|
+
</Card>
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
{/* Team Settings Section */}
|
|
920
|
+
<Card className="p-4 sm:p-6">
|
|
921
|
+
<div className="flex items-center justify-between mb-4">
|
|
922
|
+
<h2 className="text-xl font-semibold text-[var(--theme-text-primary)]">
|
|
923
|
+
Team Settings
|
|
924
|
+
</h2>
|
|
925
|
+
{session && session.role === "admin" ? (
|
|
926
|
+
<div className="space-x-2">
|
|
927
|
+
<Button
|
|
928
|
+
variant={showAddMemberForm ? "secondary" : "primary"}
|
|
929
|
+
onClick={() => setShowAddMemberForm(!showAddMemberForm)}
|
|
930
|
+
>
|
|
931
|
+
{showAddMemberForm ? "Cancel" : "Add Member"}
|
|
932
|
+
</Button>
|
|
933
|
+
<Button
|
|
934
|
+
variant={showAddApiKeyForm ? "secondary" : "primary"}
|
|
935
|
+
onClick={() => setShowAddApiKeyForm(!showAddApiKeyForm)}
|
|
936
|
+
>
|
|
937
|
+
{showAddApiKeyForm ? "Cancel" : "Add API Key"}
|
|
938
|
+
</Button>
|
|
939
|
+
</div>
|
|
940
|
+
) : null}
|
|
941
|
+
</div>
|
|
942
|
+
|
|
943
|
+
{/* Add Member Form */}
|
|
944
|
+
{showAddMemberForm && (
|
|
945
|
+
<div className="mb-4 p-4 bg-[var(--theme-bg-secondary)] rounded-lg border border-[var(--theme-border-primary)]">
|
|
946
|
+
<h3 className="text-lg font-medium text-[var(--theme-text-primary)] mb-3">
|
|
947
|
+
Add Team Member
|
|
948
|
+
</h3>
|
|
949
|
+
<div className="space-y-4">
|
|
950
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
951
|
+
<Input
|
|
952
|
+
type="text"
|
|
953
|
+
value={newMemberData.name}
|
|
954
|
+
onChange={(e) =>
|
|
955
|
+
setNewMemberData({
|
|
956
|
+
...newMemberData,
|
|
957
|
+
name: e.target.value,
|
|
958
|
+
})
|
|
959
|
+
}
|
|
960
|
+
placeholder="Member name"
|
|
961
|
+
/>
|
|
962
|
+
<Input
|
|
963
|
+
type="email"
|
|
964
|
+
value={newMemberData.email}
|
|
965
|
+
onChange={(e) =>
|
|
966
|
+
setNewMemberData({
|
|
967
|
+
...newMemberData,
|
|
968
|
+
email: e.target.value,
|
|
969
|
+
})
|
|
970
|
+
}
|
|
971
|
+
placeholder="Member email"
|
|
972
|
+
/>
|
|
973
|
+
</div>
|
|
974
|
+
<div className="mb-4">
|
|
975
|
+
<label className="block text-sm font-medium text-[var(--theme-text-primary)] mb-2">
|
|
976
|
+
Role
|
|
977
|
+
</label>
|
|
978
|
+
<select
|
|
979
|
+
value={newMemberData.role}
|
|
980
|
+
onChange={(e) =>
|
|
981
|
+
setNewMemberData({
|
|
982
|
+
...newMemberData,
|
|
983
|
+
role: e.target.value as UserRole,
|
|
984
|
+
})
|
|
985
|
+
}
|
|
986
|
+
className="w-full px-4 py-2 bg-[var(--theme-input-bg)] border border-[var(--theme-input-border)] rounded-lg text-[var(--theme-text-primary)] focus:border-[var(--theme-border-primary)] focus:outline-none transition-colors"
|
|
987
|
+
>
|
|
988
|
+
<option value="viewer">Viewer</option>
|
|
989
|
+
<option value="editor">Editor</option>
|
|
990
|
+
<option value="admin">Admin</option>
|
|
991
|
+
</select>
|
|
992
|
+
</div>
|
|
993
|
+
<div className="flex justify-end">
|
|
994
|
+
<Button
|
|
995
|
+
variant="primary"
|
|
996
|
+
onClick={handleAddMember}
|
|
997
|
+
disabled={isAddingMember}
|
|
998
|
+
>
|
|
999
|
+
{isAddingMember ? "Adding..." : "Add Member"}
|
|
1000
|
+
</Button>
|
|
1001
|
+
</div>
|
|
1002
|
+
</div>
|
|
1003
|
+
</div>
|
|
1004
|
+
)}
|
|
1005
|
+
{memberMessage ? (
|
|
1006
|
+
<div className="mb-4">
|
|
1007
|
+
<AlertBanner
|
|
1008
|
+
tone={memberMessage.type}
|
|
1009
|
+
message={memberMessage.text}
|
|
1010
|
+
onDismiss={() => setMemberMessage(null)}
|
|
1011
|
+
/>
|
|
1012
|
+
</div>
|
|
1013
|
+
) : null}
|
|
1014
|
+
|
|
1015
|
+
{/* Add API Key Form */}
|
|
1016
|
+
{showAddApiKeyForm && (
|
|
1017
|
+
<div className="mb-4 p-4 bg-[var(--theme-bg-secondary)] rounded-lg border border-[var(--theme-border-primary)]">
|
|
1018
|
+
<h3 className="text-lg font-medium text-[var(--theme-text-primary)] mb-3">
|
|
1019
|
+
Add API Key
|
|
1020
|
+
</h3>
|
|
1021
|
+
<div className="space-y-4">
|
|
1022
|
+
{/* Permissions */}
|
|
1023
|
+
<div className="mb-4">
|
|
1024
|
+
<label className="block text-sm font-medium text-[var(--theme-text-primary)] mb-2">
|
|
1025
|
+
Permissions
|
|
1026
|
+
</label>
|
|
1027
|
+
<div className="space-y-2">
|
|
1028
|
+
<label className="flex items-center space-x-2">
|
|
1029
|
+
<input
|
|
1030
|
+
type="radio"
|
|
1031
|
+
name="permissions"
|
|
1032
|
+
checked={newApiKeyData.permissions.read && !newApiKeyData.permissions.write}
|
|
1033
|
+
onChange={() =>
|
|
1034
|
+
setNewApiKeyData({
|
|
1035
|
+
...newApiKeyData,
|
|
1036
|
+
permissions: { read: true, write: false },
|
|
1037
|
+
})
|
|
1038
|
+
}
|
|
1039
|
+
className="text-blue-600"
|
|
1040
|
+
/>
|
|
1041
|
+
<span className="text-sm text-[var(--theme-text-primary)]">
|
|
1042
|
+
Read Only
|
|
1043
|
+
</span>
|
|
1044
|
+
</label>
|
|
1045
|
+
<label className="flex items-center space-x-2">
|
|
1046
|
+
<input
|
|
1047
|
+
type="radio"
|
|
1048
|
+
name="permissions"
|
|
1049
|
+
checked={newApiKeyData.permissions.read && newApiKeyData.permissions.write}
|
|
1050
|
+
onChange={() =>
|
|
1051
|
+
setNewApiKeyData({
|
|
1052
|
+
...newApiKeyData,
|
|
1053
|
+
permissions: { read: true, write: true },
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
className="text-blue-600"
|
|
1057
|
+
/>
|
|
1058
|
+
<span className="text-sm text-[var(--theme-text-primary)]">
|
|
1059
|
+
Read & Write
|
|
1060
|
+
</span>
|
|
1061
|
+
</label>
|
|
1062
|
+
</div>
|
|
1063
|
+
</div>
|
|
1064
|
+
|
|
1065
|
+
{/* Team Member Access */}
|
|
1066
|
+
<div className="mb-4">
|
|
1067
|
+
<label className="block text-sm font-medium text-[var(--theme-text-primary)] mb-2">
|
|
1068
|
+
Allowed Team Members
|
|
1069
|
+
</label>
|
|
1070
|
+
<select
|
|
1071
|
+
multiple
|
|
1072
|
+
value={newApiKeyData.allowed_team_members}
|
|
1073
|
+
onChange={(e) => {
|
|
1074
|
+
const values = Array.from(e.target.selectedOptions, option => option.value);
|
|
1075
|
+
setNewApiKeyData({
|
|
1076
|
+
...newApiKeyData,
|
|
1077
|
+
allowed_team_members: values,
|
|
1078
|
+
});
|
|
1079
|
+
}}
|
|
1080
|
+
className="w-full px-4 py-2 bg-[var(--theme-input-bg)] border border-[var(--theme-input-border)] rounded-lg text-[var(--theme-text-primary)] focus:border-[var(--theme-border-primary)] focus:outline-none min-h-[100px] transition-colors"
|
|
1081
|
+
>
|
|
1082
|
+
<option value="all">All Team Members</option>
|
|
1083
|
+
{teamMembersData?.members.map((member) => (
|
|
1084
|
+
<option key={member.id} value={member.id}>
|
|
1085
|
+
{member.name} ({member.email})
|
|
1086
|
+
</option>
|
|
1087
|
+
))}
|
|
1088
|
+
</select>
|
|
1089
|
+
<p className="text-xs text-[var(--theme-text-secondary)] mt-1">
|
|
1090
|
+
Hold Ctrl/Cmd to select multiple members
|
|
1091
|
+
</p>
|
|
1092
|
+
</div>
|
|
1093
|
+
|
|
1094
|
+
<div className="flex justify-end">
|
|
1095
|
+
<Button
|
|
1096
|
+
variant="primary"
|
|
1097
|
+
onClick={handleAddApiKey}
|
|
1098
|
+
disabled={isAddingApiKey}
|
|
1099
|
+
>
|
|
1100
|
+
{isAddingApiKey ? "Adding..." : "Add API Key"}
|
|
1101
|
+
</Button>
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
</div>
|
|
1105
|
+
)}
|
|
1106
|
+
{apiKeyMessage ? (
|
|
1107
|
+
<div className="mb-4">
|
|
1108
|
+
<AlertBanner
|
|
1109
|
+
tone={apiKeyMessage.type}
|
|
1110
|
+
message={apiKeyMessage.text}
|
|
1111
|
+
onDismiss={() => setApiKeyMessage(null)}
|
|
1112
|
+
/>
|
|
1113
|
+
</div>
|
|
1114
|
+
) : null}
|
|
1115
|
+
|
|
1116
|
+
<div className="space-y-3">
|
|
1117
|
+
{!isSessionLoading && session ? (
|
|
1118
|
+
<TeamSettings
|
|
1119
|
+
team_id={session.team?.id}
|
|
1120
|
+
role={session.role as UserRole}
|
|
1121
|
+
currentUserEmail={currentUserEmail}
|
|
1122
|
+
isSessionLoading={isSessionLoading}
|
|
1123
|
+
initialData={initialTeamSettings}
|
|
1124
|
+
onApiDataLoad={setTeamMembersData}
|
|
1125
|
+
/>
|
|
1126
|
+
) : (
|
|
1127
|
+
<div className="hidden items-center justify-center h-full">
|
|
1128
|
+
<p className="text-[var(--theme-text-secondary)]">
|
|
1129
|
+
Loading team members...
|
|
1130
|
+
</p>
|
|
1131
|
+
</div>
|
|
1132
|
+
)}
|
|
1133
|
+
</div>
|
|
1134
|
+
</Card>
|
|
1135
|
+
|
|
1136
|
+
{/* Site Tags Section */}
|
|
1137
|
+
<Card className="p-4 sm:p-6">
|
|
1138
|
+
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
1139
|
+
<h2 className="text-xl font-semibold text-[var(--theme-text-primary)]">
|
|
1140
|
+
Site Tag
|
|
1141
|
+
</h2>
|
|
1142
|
+
{session && session.role === "admin" ? (
|
|
1143
|
+
<Button
|
|
1144
|
+
variant={showAddSiteForm ? "secondary" : "primary"}
|
|
1145
|
+
onClick={() => setShowAddSiteForm(!showAddSiteForm)}
|
|
1146
|
+
className="w-full sm:w-auto"
|
|
1147
|
+
>
|
|
1148
|
+
{showAddSiteForm ? "Cancel" : "Add New Site"}
|
|
1149
|
+
</Button>
|
|
1150
|
+
) : null}
|
|
1151
|
+
</div>
|
|
1152
|
+
|
|
1153
|
+
{/* Add Site Form */}
|
|
1154
|
+
{showAddSiteForm && (
|
|
1155
|
+
<div className="mb-6 p-4 bg-[var(--theme-bg-secondary)] rounded-lg border border-[var(--theme-border-primary)]">
|
|
1156
|
+
<h3 className="text-lg font-medium text-[var(--theme-text-primary)] mb-3">
|
|
1157
|
+
Add New Site
|
|
1158
|
+
</h3>
|
|
1159
|
+
<div className="space-y-4">
|
|
1160
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1161
|
+
<Input
|
|
1162
|
+
type="text"
|
|
1163
|
+
value={newSiteData.name}
|
|
1164
|
+
onChange={(e) =>
|
|
1165
|
+
setNewSiteData({ ...newSiteData, name: e.target.value })
|
|
1166
|
+
}
|
|
1167
|
+
placeholder="Site name"
|
|
1168
|
+
/>
|
|
1169
|
+
<Input
|
|
1170
|
+
type="text"
|
|
1171
|
+
value={newSiteData.domain}
|
|
1172
|
+
onChange={(e) =>
|
|
1173
|
+
setNewSiteData({ ...newSiteData, domain: e.target.value })
|
|
1174
|
+
}
|
|
1175
|
+
placeholder="Domain (e.g., example.com)"
|
|
1176
|
+
/>
|
|
1177
|
+
</div>
|
|
1178
|
+
<div className="space-y-3">
|
|
1179
|
+
<label className="flex items-center space-x-2">
|
|
1180
|
+
<input
|
|
1181
|
+
type="checkbox"
|
|
1182
|
+
checked={newSiteData.track_web_events}
|
|
1183
|
+
onChange={(e) =>
|
|
1184
|
+
setNewSiteData({
|
|
1185
|
+
...newSiteData,
|
|
1186
|
+
track_web_events: e.target.checked,
|
|
1187
|
+
})
|
|
1188
|
+
}
|
|
1189
|
+
className="rounded border-[var(--theme-border-primary)]"
|
|
1190
|
+
/>
|
|
1191
|
+
<span className="text-sm text-[var(--theme-text-primary)]">
|
|
1192
|
+
Track web events
|
|
1193
|
+
</span>
|
|
1194
|
+
</label>
|
|
1195
|
+
<label className="flex items-center space-x-2">
|
|
1196
|
+
<input
|
|
1197
|
+
type="checkbox"
|
|
1198
|
+
checked={newSiteData.event_load_strategy === "kv"}
|
|
1199
|
+
onChange={(e) =>
|
|
1200
|
+
setNewSiteData({
|
|
1201
|
+
...newSiteData,
|
|
1202
|
+
event_load_strategy: e.target.checked ? "kv" : "sdk",
|
|
1203
|
+
})
|
|
1204
|
+
}
|
|
1205
|
+
className="rounded border-[var(--theme-border-primary)]"
|
|
1206
|
+
/>
|
|
1207
|
+
<span className="text-sm text-[var(--theme-text-primary)]">
|
|
1208
|
+
Load events from KV
|
|
1209
|
+
</span>
|
|
1210
|
+
</label>
|
|
1211
|
+
<label className="flex items-center space-x-2">
|
|
1212
|
+
<input
|
|
1213
|
+
type="checkbox"
|
|
1214
|
+
checked={newSiteData.gdpr}
|
|
1215
|
+
onChange={(e) =>
|
|
1216
|
+
setNewSiteData({
|
|
1217
|
+
...newSiteData,
|
|
1218
|
+
gdpr: e.target.checked,
|
|
1219
|
+
})
|
|
1220
|
+
}
|
|
1221
|
+
className="rounded border-[var(--theme-border-primary)]"
|
|
1222
|
+
/>
|
|
1223
|
+
<span className="text-sm text-[var(--theme-text-primary)]">
|
|
1224
|
+
GDPR compliant
|
|
1225
|
+
</span>
|
|
1226
|
+
</label>
|
|
1227
|
+
<div>
|
|
1228
|
+
<label className="flex items-center space-x-2">
|
|
1229
|
+
<input
|
|
1230
|
+
type="checkbox"
|
|
1231
|
+
checked={newSiteData.autocapture}
|
|
1232
|
+
onChange={(e) =>
|
|
1233
|
+
setNewSiteData({
|
|
1234
|
+
...newSiteData,
|
|
1235
|
+
autocapture: e.target.checked,
|
|
1236
|
+
})
|
|
1237
|
+
}
|
|
1238
|
+
className="rounded border-[var(--theme-border-primary)]"
|
|
1239
|
+
/>
|
|
1240
|
+
<span className="text-sm text-[var(--theme-text-primary)]">
|
|
1241
|
+
Autocapture
|
|
1242
|
+
</span>
|
|
1243
|
+
</label>
|
|
1244
|
+
<p className="text-xs text-[var(--theme-text-secondary)] mt-1 ml-6">
|
|
1245
|
+
Automatically track clicks on links, buttons, and form submissions
|
|
1246
|
+
</p>
|
|
1247
|
+
</div>
|
|
1248
|
+
</div>
|
|
1249
|
+
<div className="flex justify-end">
|
|
1250
|
+
<Button
|
|
1251
|
+
variant="primary"
|
|
1252
|
+
onClick={handleAddSite}
|
|
1253
|
+
disabled={isAddingSite}
|
|
1254
|
+
>
|
|
1255
|
+
{isAddingSite ? "Adding..." : "Add Site"}
|
|
1256
|
+
</Button>
|
|
1257
|
+
</div>
|
|
1258
|
+
</div>
|
|
1259
|
+
</div>
|
|
1260
|
+
)}
|
|
1261
|
+
{siteMessage ? (
|
|
1262
|
+
<div className="mb-6">
|
|
1263
|
+
<AlertBanner
|
|
1264
|
+
tone={siteMessage.type}
|
|
1265
|
+
message={siteMessage.text}
|
|
1266
|
+
onDismiss={() => setSiteMessage(null)}
|
|
1267
|
+
/>
|
|
1268
|
+
</div>
|
|
1269
|
+
) : null}
|
|
1270
|
+
|
|
1271
|
+
<div className="mb-6">
|
|
1272
|
+
<label className="block text-sm font-medium text-[var(--theme-text-primary)] mb-2">
|
|
1273
|
+
Site
|
|
1274
|
+
</label>
|
|
1275
|
+
<SiteSelector
|
|
1276
|
+
initialSites={initialSites}
|
|
1277
|
+
initialSiteId={initialSiteId}
|
|
1278
|
+
wrapperClassName="w-full sm:w-[260px]"
|
|
1279
|
+
selectClassName="w-full"
|
|
1280
|
+
/>
|
|
1281
|
+
</div>
|
|
1282
|
+
|
|
1283
|
+
<div className="space-y-6">
|
|
1284
|
+
{currentSiteTag ? (
|
|
1285
|
+
<SiteTagInstallCard site={currentSiteTag} />
|
|
1286
|
+
) : (
|
|
1287
|
+
<div className="text-center py-8">
|
|
1288
|
+
<p className="text-[var(--theme-text-secondary)]">
|
|
1289
|
+
{isSessionLoading
|
|
1290
|
+
? "Loading site information..."
|
|
1291
|
+
: "No site selected"}
|
|
1292
|
+
</p>
|
|
1293
|
+
</div>
|
|
1294
|
+
)}
|
|
1295
|
+
</div>
|
|
1296
|
+
</Card>
|
|
1297
|
+
</div>
|
|
1298
|
+
</div>
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
export default SettingsPage;
|