lytx 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.env.example +37 -0
  2. package/README.md +486 -0
  3. package/alchemy.run.ts +155 -0
  4. package/cli/bootstrap-admin.ts +284 -0
  5. package/cli/deploy-staging.ts +692 -0
  6. package/cli/import-events.ts +628 -0
  7. package/cli/import-sites.ts +518 -0
  8. package/cli/index.ts +609 -0
  9. package/cli/init-db.ts +269 -0
  10. package/cli/migrate-to-durable-objects.ts +564 -0
  11. package/cli/migration-worker.ts +300 -0
  12. package/cli/performance-test.ts +588 -0
  13. package/cli/pg/client.ts +4 -0
  14. package/cli/pg/new-site.ts +153 -0
  15. package/cli/rollback-durable-objects.ts +622 -0
  16. package/cli/seed-data.ts +459 -0
  17. package/cli/setup.js +18 -0
  18. package/cli/setup.ts +463 -0
  19. package/cli/validate-migration.ts +200 -0
  20. package/cli/wrangler-migration.jsonc +28 -0
  21. package/db/adapter.ts +166 -0
  22. package/db/analytics_engine/client.ts +0 -0
  23. package/db/analytics_engine/sites.ts +0 -0
  24. package/db/client.ts +16 -0
  25. package/db/d1/client.ts +8 -0
  26. package/db/d1/drizzle.config.ts +35 -0
  27. package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
  28. package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
  29. package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
  30. package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
  31. package/db/d1/migrations/0004_mute_stardust.sql +1 -0
  32. package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
  33. package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
  34. package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
  35. package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
  36. package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
  37. package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
  38. package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
  39. package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
  40. package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
  41. package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
  42. package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
  43. package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
  44. package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
  45. package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
  46. package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
  47. package/db/d1/migrations/meta/_journal.json +76 -0
  48. package/db/d1/schema.ts +407 -0
  49. package/db/d1/sites.ts +374 -0
  50. package/db/d1/teamAiUsage.ts +101 -0
  51. package/db/d1/teams.ts +127 -0
  52. package/db/durable/drizzle.config.ts +8 -0
  53. package/db/durable/durableObjectClient.ts +480 -0
  54. package/db/durable/events.ts +100 -0
  55. package/db/durable/migrations/0000_fair_bucky.sql +38 -0
  56. package/db/durable/migrations/meta/0000_snapshot.json +278 -0
  57. package/db/durable/migrations/meta/_journal.json +13 -0
  58. package/db/durable/migrations/migrations.js +10 -0
  59. package/db/durable/schema.ts +5 -0
  60. package/db/durable/siteDurableObject.ts +1352 -0
  61. package/db/durable/types.ts +53 -0
  62. package/db/postgres/client.ts +13 -0
  63. package/db/postgres/drizzle.config.ts +12 -0
  64. package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
  65. package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
  66. package/db/postgres/migrations/meta/_journal.json +13 -0
  67. package/db/postgres/schema.ts +145 -0
  68. package/db/postgres/sites.ts +118 -0
  69. package/db/tranformReports.ts +595 -0
  70. package/db/types.ts +55 -0
  71. package/endpoints/api_worker.tsx +1854 -0
  72. package/endpoints/site_do_worker.ts +11 -0
  73. package/index.d.ts +63 -0
  74. package/index.ts +83 -0
  75. package/lib/auth.ts +279 -0
  76. package/lib/geojson/world_countries.json +45307 -0
  77. package/lib/random_name.ts +41 -0
  78. package/lib/sendMail.ts +252 -0
  79. package/package.json +142 -0
  80. package/public/favicon.ico +0 -0
  81. package/public/images/android-chrome-192x192.png +0 -0
  82. package/public/images/android-chrome-512x512.png +0 -0
  83. package/public/images/apple-touch-icon.png +0 -0
  84. package/public/images/favicon-16x16.png +0 -0
  85. package/public/images/favicon-32x32.png +0 -0
  86. package/public/images/lytx_dark_dashboard.png +0 -0
  87. package/public/images/lytx_light_dashboard.png +0 -0
  88. package/public/images/safari-pinned-tab.svg +4 -0
  89. package/public/logo.png +0 -0
  90. package/public/site.webmanifest +26 -0
  91. package/public/sw.js +107 -0
  92. package/src/Document.tsx +86 -0
  93. package/src/api/ai_api.ts +1156 -0
  94. package/src/api/authMiddleware.ts +45 -0
  95. package/src/api/auth_api.ts +465 -0
  96. package/src/api/event_labels_api.ts +193 -0
  97. package/src/api/events_api.ts +210 -0
  98. package/src/api/queueWorker.ts +303 -0
  99. package/src/api/reports_api.ts +278 -0
  100. package/src/api/seed_api.ts +288 -0
  101. package/src/api/sites_api.ts +904 -0
  102. package/src/api/tag_api.ts +458 -0
  103. package/src/api/tag_api_v2.ts +289 -0
  104. package/src/api/team_api.ts +456 -0
  105. package/src/app/Dashboard.tsx +1339 -0
  106. package/src/app/Events.tsx +974 -0
  107. package/src/app/Explore.tsx +312 -0
  108. package/src/app/Layout.tsx +58 -0
  109. package/src/app/Settings.tsx +1302 -0
  110. package/src/app/components/DashboardCard.tsx +118 -0
  111. package/src/app/components/EditableCell.tsx +123 -0
  112. package/src/app/components/EventForm.tsx +93 -0
  113. package/src/app/components/MarketingFooter.tsx +49 -0
  114. package/src/app/components/MarketingNav.tsx +150 -0
  115. package/src/app/components/Nav.tsx +755 -0
  116. package/src/app/components/NewSiteSetup.tsx +298 -0
  117. package/src/app/components/SQLEditor.tsx +740 -0
  118. package/src/app/components/SiteSelector.tsx +126 -0
  119. package/src/app/components/SiteTag.tsx +42 -0
  120. package/src/app/components/SiteTagInstallCard.tsx +241 -0
  121. package/src/app/components/WorldMapCard.tsx +337 -0
  122. package/src/app/components/charts/ChartComponents.tsx +1481 -0
  123. package/src/app/components/charts/EventFunnel.tsx +45 -0
  124. package/src/app/components/charts/EventSummary.tsx +194 -0
  125. package/src/app/components/charts/SankeyFlows.tsx +72 -0
  126. package/src/app/components/marketing/CheckIcon.tsx +16 -0
  127. package/src/app/components/marketing/MarketingLayout.tsx +23 -0
  128. package/src/app/components/marketing/SectionHeading.tsx +35 -0
  129. package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
  130. package/src/app/components/reports/CreateReportStarter.tsx +74 -0
  131. package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
  132. package/src/app/components/reports/DashboardToolbar.tsx +154 -0
  133. package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
  134. package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
  135. package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
  136. package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
  137. package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
  138. package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
  139. package/src/app/components/reports/custom/chartPalettes.ts +18 -0
  140. package/src/app/components/reports/custom/types.ts +50 -0
  141. package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
  142. package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
  143. package/src/app/components/ui/AlertBanner.tsx +101 -0
  144. package/src/app/components/ui/Button.tsx +55 -0
  145. package/src/app/components/ui/Card.tsx +80 -0
  146. package/src/app/components/ui/Input.tsx +72 -0
  147. package/src/app/components/ui/Link.tsx +23 -0
  148. package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
  149. package/src/app/components/ui/ThemeToggle.tsx +54 -0
  150. package/src/app/constants.ts +6 -0
  151. package/src/app/headers.ts +33 -0
  152. package/src/app/providers/AuthProvider.tsx +189 -0
  153. package/src/app/providers/ClientProviders.tsx +18 -0
  154. package/src/app/providers/QueryProvider.tsx +23 -0
  155. package/src/app/providers/ThemeProvider.tsx +88 -0
  156. package/src/app/utils/chartThemes.ts +146 -0
  157. package/src/app/utils/keybinds.ts +96 -0
  158. package/src/app/utils/media.tsx +24 -0
  159. package/src/client.tsx +114 -0
  160. package/src/config/createLytxAppConfig.ts +252 -0
  161. package/src/config/resourceNames.ts +88 -0
  162. package/src/db/index.ts +67 -0
  163. package/src/index.css +285 -0
  164. package/src/lib/featureFlags.ts +69 -0
  165. package/src/pages/GetStarted.tsx +290 -0
  166. package/src/pages/Home.tsx +268 -0
  167. package/src/pages/Login.tsx +283 -0
  168. package/src/pages/PrivacyPolicy.tsx +120 -0
  169. package/src/pages/Signup.tsx +267 -0
  170. package/src/pages/TermsOfService.tsx +126 -0
  171. package/src/pages/VerifyEmail.tsx +56 -0
  172. package/src/session/durableObject.ts +7 -0
  173. package/src/session/siteSchema.ts +86 -0
  174. package/src/session/types.ts +36 -0
  175. package/src/templates/README.md +80 -0
  176. package/src/templates/cleanFunctions.js +44 -0
  177. package/src/templates/embedFunctions.js +52 -0
  178. package/src/templates/lytx-shared.ts +662 -0
  179. package/src/templates/lytxpixel-core.ts +144 -0
  180. package/src/templates/lytxpixel.ts +267 -0
  181. package/src/templates/lytxpixelBrowser.js +634 -0
  182. package/src/templates/lytxpixelBrowser.mjs +634 -0
  183. package/src/templates/parseData.js +12 -0
  184. package/src/templates/script.ts +31 -0
  185. package/src/templates/template.tsx +50 -0
  186. package/src/templates/test.js +3 -0
  187. package/src/templates/trackWebEvents.ts +177 -0
  188. package/src/templates/vendors/clickcease.ts +8 -0
  189. package/src/templates/vendors/google.ts +174 -0
  190. package/src/templates/vendors/linkedin.ts +23 -0
  191. package/src/templates/vendors/meta.ts +56 -0
  192. package/src/templates/vendors/quantcast.ts +22 -0
  193. package/src/templates/vendors/simplfi.ts +7 -0
  194. package/src/types/app-context.ts +16 -0
  195. package/src/utilities/dashboardParams.ts +188 -0
  196. package/src/utilities/dashboardQueries.ts +537 -0
  197. package/src/utilities/dashboardTransforms.ts +167 -0
  198. package/src/utilities/dataValidation.ts +414 -0
  199. package/src/utilities/detector.ts +73 -0
  200. package/src/utilities/encrypt.ts +103 -0
  201. package/src/utilities/index.ts +13 -0
  202. package/src/utilities/parser.ts +117 -0
  203. package/src/utilities/performanceMonitoring.ts +570 -0
  204. package/src/utilities/route_interuptors.ts +24 -0
  205. package/src/worker.tsx +675 -0
  206. package/tsconfig.json +78 -0
  207. package/types/env.d.ts +16 -0
  208. package/types/rw.d.ts +7 -0
  209. package/types/shims.d.ts +53 -0
  210. package/types/vite.d.ts +19 -0
  211. package/vite/vite-plugin-pixel-bundle.ts +126 -0
  212. package/vite.config.ts +53 -0
  213. package/worker-configuration.d.ts +8401 -0
@@ -0,0 +1,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;