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,755 @@
1
+ "use client";
2
+ import { type FormEvent, useContext, useEffect, useRef, useState } from "react";
3
+ import { AuthContext, signOut } from "@/app/providers/AuthProvider";
4
+ import { ThemeToggle } from "./ui/ThemeToggle";
5
+ import { Link } from "./ui/Link";
6
+
7
+ type TeamInfo = {
8
+ id: number;
9
+ name?: string | null;
10
+ external_id?: number | null;
11
+ };
12
+
13
+ export type NavInitialSession = {
14
+ user?: {
15
+ name?: string | null;
16
+ email?: string | null;
17
+ image?: string | null;
18
+ } | null;
19
+ team?: TeamInfo | null;
20
+ all_teams?: TeamInfo[] | null;
21
+ };
22
+
23
+ const LAST_TEAM_KEY = "lytx_last_team_id";
24
+
25
+ const getLastTeamFromStorage = (): number | null => {
26
+ try {
27
+ const stored = localStorage.getItem(LAST_TEAM_KEY);
28
+ return stored ? Number.parseInt(stored, 10) : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ const saveLastTeamToStorage = (teamId: number): void => {
35
+ try {
36
+ localStorage.setItem(LAST_TEAM_KEY, String(teamId));
37
+ } catch {
38
+ // Ignore storage errors
39
+ }
40
+ };
41
+
42
+ type NavProps = {
43
+ initialSession?: NavInitialSession | null;
44
+ };
45
+
46
+ export function Nav({ initialSession = null }: NavProps) {
47
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
48
+ const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
49
+ const [isTeamMenuOpen, setIsTeamMenuOpen] = useState(false);
50
+ const [isCreateTeamOpen, setIsCreateTeamOpen] = useState(false);
51
+ const [createTeamName, setCreateTeamName] = useState("");
52
+ const [createTeamError, setCreateTeamError] = useState("");
53
+ const [isCreatingTeam, setIsCreatingTeam] = useState(false);
54
+ const [switchingTeamId, setSwitchingTeamId] = useState<number | null>(null);
55
+ const [activeTeamId, setActiveTeamId] = useState<number | null>(null);
56
+ const menuRef = useRef<HTMLDivElement>(null);
57
+ const mobileMenuRef = useRef<HTMLDivElement>(null);
58
+ const { data: authSession, refetch, setCurrentSite } = useContext(AuthContext);
59
+ const session = authSession ?? initialSession;
60
+ const user = session?.user as { name?: string | null; email?: string | null; image?: string | null } | undefined;
61
+ const sessionTeamId = session?.team?.id ?? null;
62
+ const rawTeams = (session?.all_teams ?? []) as TeamInfo[];
63
+ const teamMap = new Map<number, TeamInfo>();
64
+ for (const team of rawTeams) {
65
+ if (team?.id != null) teamMap.set(team.id, team);
66
+ }
67
+ if (!teamMap.size && session?.team?.id) {
68
+ teamMap.set(session.team.id, {
69
+ id: session.team.id,
70
+ name: session.team.name,
71
+ external_id: session.team.external_id,
72
+ });
73
+ }
74
+ const teamList = Array.from(teamMap.values()).toSorted((a, b) => {
75
+ if (a.id === sessionTeamId) return -1;
76
+ if (b.id === sessionTeamId) return 1;
77
+ return (a.name || "").localeCompare(b.name || "");
78
+ });
79
+ const userName = user?.name?.trim() || "";
80
+ const userEmail = user?.email?.trim() || "";
81
+ const userImage = user?.image || "";
82
+
83
+ const getInitials = (name: string, email: string) => {
84
+ if (name) {
85
+ const parts = name.split(" ").filter(Boolean);
86
+ const first = parts[0]?.[0] || "";
87
+ const last = parts.length > 1 ? parts[parts.length - 1]?.[0] : "";
88
+ const initials = `${first}${last}` || first;
89
+ return initials.toUpperCase() || "U";
90
+ }
91
+
92
+ if (email) {
93
+ const localPart = email.split("@")[0] || "";
94
+ const letters = localPart.replace(/[^a-zA-Z0-9]/g, "");
95
+ return (letters.slice(0, 2) || "U").toUpperCase();
96
+ }
97
+
98
+ return "U";
99
+ };
100
+
101
+ const userInitials = getInitials(userName, userEmail);
102
+ const resolvedTeamId =
103
+ activeTeamId && teamList.some((team) => team.id === activeTeamId)
104
+ ? activeTeamId
105
+ : sessionTeamId;
106
+ const currentTeam =
107
+ teamList.find((team) => team.id === resolvedTeamId) ?? teamList[0] ?? null;
108
+ const otherTeams = teamList.filter((team) => team.id !== resolvedTeamId);
109
+ const teamButtonBaseClass =
110
+ "flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left text-sm text-[var(--theme-text-primary)] transition-colors disabled:cursor-not-allowed disabled:opacity-60";
111
+
112
+ const renderTeamButton = (team: TeamInfo, isCurrent: boolean) => {
113
+ const isSwitching = switchingTeamId === team.id;
114
+ return (
115
+ <button
116
+ key={team.id}
117
+ type="button"
118
+ className={`${teamButtonBaseClass} ${isCurrent
119
+ ? "bg-[var(--theme-bg-secondary)]"
120
+ : "hover:text-[var(--color-primary)]"
121
+ }`}
122
+ aria-current={isCurrent ? "true" : undefined}
123
+ onClick={() => handleSwitchTeam(team.id)}
124
+ disabled={switchingTeamId !== null}
125
+ >
126
+ <span className="truncate">{team.name || "Untitled team"}</span>
127
+ {isCurrent ? (
128
+ <svg
129
+ width="14"
130
+ height="14"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="2"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ className="text-[var(--color-primary)]"
138
+ aria-hidden="true"
139
+ >
140
+ <polyline points="20 6 9 17 4 12"></polyline>
141
+ </svg>
142
+ ) : isSwitching ? (
143
+ <span className="text-[10px] text-[var(--theme-text-secondary)]">Switching</span>
144
+ ) : null}
145
+ </button>
146
+ );
147
+ };
148
+
149
+ const openCreateTeamModal = () => {
150
+ setIsCreateTeamOpen(true);
151
+ setCreateTeamError("");
152
+ setIsUserMenuOpen(false);
153
+ setIsMenuOpen(false);
154
+ setIsTeamMenuOpen(false);
155
+ };
156
+
157
+ const closeCreateTeamModal = () => {
158
+ setIsCreateTeamOpen(false);
159
+ setCreateTeamError("");
160
+ setCreateTeamName("");
161
+ };
162
+
163
+ const handleCreateTeam = async (event: FormEvent<HTMLFormElement>) => {
164
+ event.preventDefault();
165
+ if (isCreatingTeam) return;
166
+ const trimmedName = createTeamName.trim();
167
+ if (!trimmedName) {
168
+ setCreateTeamError("Team name is required");
169
+ return;
170
+ }
171
+ if (trimmedName.length < 2) {
172
+ setCreateTeamError("Team name must be at least 2 characters");
173
+ return;
174
+ }
175
+ if (trimmedName.length > 80) {
176
+ setCreateTeamError("Team name must be 80 characters or less");
177
+ return;
178
+ }
179
+
180
+ setIsCreatingTeam(true);
181
+ setCreateTeamError("");
182
+
183
+ try {
184
+ const response = await fetch("/api/team/create", {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify({ name: trimmedName }),
188
+ });
189
+
190
+ const data = (await response.json().catch(() => null)) as
191
+ | { error?: string; team_id?: number }
192
+ | null;
193
+
194
+ if (!response.ok) {
195
+ throw new Error(data?.error || "Failed to create team");
196
+ }
197
+
198
+ closeCreateTeamModal();
199
+
200
+ if (data?.team_id) {
201
+ await handleSwitchTeam(data.team_id);
202
+ }
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : "Failed to create team";
205
+ setCreateTeamError(message);
206
+ } finally {
207
+ setIsCreatingTeam(false);
208
+ }
209
+ };
210
+
211
+ const handleSwitchTeam = async (teamId: number) => {
212
+ if (teamId === resolvedTeamId) {
213
+ setIsUserMenuOpen(false);
214
+ setIsMenuOpen(false);
215
+ setIsTeamMenuOpen(false);
216
+ return;
217
+ }
218
+ if (switchingTeamId) return;
219
+
220
+ setSwitchingTeamId(teamId);
221
+ try {
222
+ const response = await fetch("/api/user/update-last-team", {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify({ team_id: teamId }),
226
+ });
227
+
228
+ const data = (await response.json().catch(() => null)) as
229
+ | {
230
+ error?: string;
231
+ userSites?: Array<{ site_id: number; name?: string | null; tag_id: string }>;
232
+ }
233
+ | null;
234
+
235
+ if (!response.ok) {
236
+ throw new Error(data?.error || "Failed to switch team");
237
+ }
238
+
239
+ setActiveTeamId(teamId);
240
+ saveLastTeamToStorage(teamId);
241
+
242
+ await refetch();
243
+
244
+ if (!data?.userSites || data.userSites.length === 0) {
245
+ window.location.assign("/dashboard/new-site");
246
+ return;
247
+ }
248
+
249
+ const nextSite = data.userSites[0];
250
+ if (nextSite) {
251
+ setCurrentSite({
252
+ name: nextSite.name || "",
253
+ id: nextSite.site_id,
254
+ tag_id: nextSite.tag_id,
255
+ });
256
+ if (
257
+ window.location.pathname === "/dashboard/new-site" ||
258
+ window.location.pathname === "/new-site"
259
+ ) {
260
+ window.location.assign("/dashboard");
261
+ return;
262
+ }
263
+ } else {
264
+ setCurrentSite(null);
265
+ }
266
+ } catch (error) {
267
+ console.error("Error switching team:", error);
268
+ } finally {
269
+ setSwitchingTeamId(null);
270
+ setIsUserMenuOpen(false);
271
+ setIsMenuOpen(false);
272
+ setIsTeamMenuOpen(false);
273
+ }
274
+ };
275
+
276
+ useEffect(() => {
277
+ if (!isUserMenuOpen) return;
278
+ const handleClickOutside = (event: MouseEvent) => {
279
+ const target = event.target as Node;
280
+ if (menuRef.current?.contains(target)) return;
281
+ if (mobileMenuRef.current?.contains(target)) return;
282
+ setIsUserMenuOpen(false);
283
+ };
284
+ document.addEventListener("mousedown", handleClickOutside);
285
+ return () => document.removeEventListener("mousedown", handleClickOutside);
286
+ }, [isUserMenuOpen]);
287
+
288
+ useEffect(() => {
289
+ if (!isUserMenuOpen) setIsTeamMenuOpen(false);
290
+ }, [isUserMenuOpen]);
291
+
292
+ const hasReconciledTeam = useRef(false);
293
+ useEffect(() => {
294
+ if (!teamList.length || hasReconciledTeam.current) return;
295
+ const storedTeamId = getLastTeamFromStorage();
296
+ if (storedTeamId && teamList.some((team) => team.id === storedTeamId)) {
297
+ if (storedTeamId !== sessionTeamId) {
298
+ hasReconciledTeam.current = true;
299
+ handleSwitchTeam(storedTeamId);
300
+ } else {
301
+ setActiveTeamId(storedTeamId);
302
+ }
303
+ return;
304
+ }
305
+ if (sessionTeamId) {
306
+ setActiveTeamId(sessionTeamId);
307
+ saveLastTeamToStorage(sessionTeamId);
308
+ }
309
+ }, [sessionTeamId, teamList]);
310
+
311
+ return (
312
+ <>
313
+ <nav className="flex justify-between items-center p-4 bg-[var(--theme-bg-primary)] border-b border-[var(--theme-border-primary)]">
314
+ <Link href="/dashboard" className="logo flex items-center gap-2">
315
+ <img src="/logo.png" alt="Lytx logo" className="h-6 w-6" />
316
+ <span className="text-xl font-bold text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors">
317
+ Lytx
318
+ </span>
319
+ </Link>
320
+
321
+ {/* Desktop navigation */}
322
+ <div className="hidden md:flex items-center gap-4">
323
+ <ul className="flex gap-6">
324
+ <li className="cursor-pointer">
325
+ <Link
326
+ href="/dashboard"
327
+ className="text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
328
+ >
329
+ Overview
330
+ </Link>
331
+ </li>
332
+ <li className="cursor-pointer">
333
+ <Link
334
+ href="/dashboard/events"
335
+ className="text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
336
+ >
337
+ Events
338
+ </Link>
339
+ </li>
340
+ <li className="cursor-pointer">
341
+ <Link
342
+ href="/dashboard/explore"
343
+ className="text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
344
+ >
345
+ Explore
346
+ </Link>
347
+ </li>
348
+ </ul>
349
+ <div className="relative" ref={menuRef}>
350
+ <button
351
+ type="button"
352
+ onClick={() => setIsUserMenuOpen((open) => !open)}
353
+ className="flex items-center gap-2 rounded-full border border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] p-1 text-[var(--theme-text-primary)] hover:border-[var(--color-primary)] transition-colors"
354
+ aria-label="Open user menu"
355
+ aria-expanded={isUserMenuOpen}
356
+ >
357
+ {userImage ? (
358
+ <img
359
+ src={userImage}
360
+ alt={userName ? `${userName} avatar` : "User avatar"}
361
+ className="h-8 w-8 rounded-full object-cover"
362
+ />
363
+ ) : (
364
+ <span className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--theme-border-primary)] bg-[var(--theme-card-bg)] text-xs font-semibold text-[var(--theme-text-primary)]">
365
+ {userInitials}
366
+ </span>
367
+ )}
368
+ <svg
369
+ width="14"
370
+ height="14"
371
+ viewBox="0 0 24 24"
372
+ fill="none"
373
+ stroke="currentColor"
374
+ strokeWidth="2"
375
+ strokeLinecap="round"
376
+ strokeLinejoin="round"
377
+ className="opacity-70"
378
+ aria-hidden="true"
379
+ >
380
+ <polyline points="6 9 12 15 18 9"></polyline>
381
+ </svg>
382
+ </button>
383
+ {isUserMenuOpen && (
384
+ <div className="absolute right-0 mt-2 w-52 min-w-[13rem] rounded-md border border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] shadow-lg z-[80]">
385
+ <div className="px-3 py-2 text-xs text-[var(--theme-text-secondary)]">
386
+ {userName || userEmail || "Signed in"}
387
+ </div>
388
+ {teamList.length > 0 && (
389
+ <div className="border-t border-[var(--theme-border-primary)] py-1">
390
+ <div
391
+ className="relative"
392
+ onMouseEnter={() => setIsTeamMenuOpen(true)}
393
+ onMouseLeave={() => setIsTeamMenuOpen(false)}
394
+ >
395
+ <span
396
+ className="pointer-events-none absolute -right-3 -top-2 h-[calc(100%+1rem)] w-3"
397
+ aria-hidden="true"
398
+ />
399
+ <button
400
+ type="button"
401
+ className="flex w-full cursor-pointer items-center justify-between px-3 py-2 text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
402
+ onClick={() => setIsTeamMenuOpen((open) => !open)}
403
+ aria-haspopup="menu"
404
+ aria-expanded={isTeamMenuOpen}
405
+ >
406
+ Switch Team
407
+ <svg
408
+ width="14"
409
+ height="14"
410
+ viewBox="0 0 24 24"
411
+ fill="none"
412
+ stroke="currentColor"
413
+ strokeWidth="2"
414
+ strokeLinecap="round"
415
+ strokeLinejoin="round"
416
+ className="opacity-60"
417
+ aria-hidden="true"
418
+ >
419
+ <polyline points="9 6 15 12 9 18"></polyline>
420
+ </svg>
421
+ </button>
422
+ <div
423
+ className={`absolute right-full -top-1 -mr-0.5 w-64 min-w-[16rem] rounded-md border border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] shadow-lg z-[90] ${isTeamMenuOpen ? "block" : "hidden"
424
+ }`}
425
+ >
426
+ <div className="px-3 pt-2 text-[11px] uppercase tracking-wide text-[var(--theme-text-secondary)]">
427
+ Current team
428
+ </div>
429
+ {currentTeam ? renderTeamButton(currentTeam, true) : null}
430
+ {otherTeams.length > 0 && (
431
+ <div className="border-t border-[var(--theme-border-primary)] mt-1 pt-1">
432
+ <div className="px-3 pt-2 text-[11px] uppercase tracking-wide text-[var(--theme-text-secondary)]">
433
+ Other teams
434
+ </div>
435
+ {otherTeams.map((team) => renderTeamButton(team, false))}
436
+ </div>
437
+ )}
438
+ <div className="border-t border-[var(--theme-border-primary)] mt-1 pt-1">
439
+ <button
440
+ type="button"
441
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors disabled:cursor-not-allowed disabled:opacity-60"
442
+ onClick={openCreateTeamModal}
443
+ disabled={isCreatingTeam || switchingTeamId !== null}
444
+ >
445
+ <span className="flex h-5 w-5 items-center justify-center rounded-full border border-[var(--theme-border-primary)] text-xs">
446
+ +
447
+ </span>
448
+ Create a team
449
+ </button>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ </div>
454
+ )}
455
+ <Link
456
+ href="/dashboard/settings"
457
+ className="block cursor-pointer px-3 py-2 text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
458
+ onClick={() => setIsUserMenuOpen(false)}
459
+ >
460
+ Settings
461
+ </Link>
462
+ <div className="flex items-center justify-between px-3 py-2 text-sm text-[var(--theme-text-primary)]">
463
+ <span>Theme</span>
464
+ <ThemeToggle />
465
+ </div>
466
+ <button
467
+ className="w-full cursor-pointer px-3 py-2 text-left text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
468
+ onClick={() => {
469
+ setIsUserMenuOpen(false);
470
+ signOut();
471
+ }}
472
+ >
473
+ Sign Out
474
+ </button>
475
+ </div>
476
+ )}
477
+ </div>
478
+ </div>
479
+
480
+ {/* Mobile hamburger button */}
481
+ <div className="flex md:hidden items-center gap-2">
482
+ <button
483
+ onClick={() => setIsMenuOpen(!isMenuOpen)}
484
+ className="p-2 text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
485
+ aria-label="Toggle menu"
486
+ >
487
+ {isMenuOpen ? (
488
+ <svg
489
+ width="24"
490
+ height="24"
491
+ viewBox="0 0 24 24"
492
+ fill="none"
493
+ stroke="currentColor"
494
+ strokeWidth="2"
495
+ strokeLinecap="round"
496
+ strokeLinejoin="round"
497
+ >
498
+ <line x1="18" y1="6" x2="6" y2="18"></line>
499
+ <line x1="6" y1="6" x2="18" y2="18"></line>
500
+ </svg>
501
+ ) : (
502
+ <svg
503
+ width="24"
504
+ height="24"
505
+ viewBox="0 0 24 24"
506
+ fill="none"
507
+ stroke="currentColor"
508
+ strokeWidth="2"
509
+ strokeLinecap="round"
510
+ strokeLinejoin="round"
511
+ >
512
+ <line x1="3" y1="12" x2="21" y2="12"></line>
513
+ <line x1="3" y1="6" x2="21" y2="6"></line>
514
+ <line x1="3" y1="18" x2="21" y2="18"></line>
515
+ </svg>
516
+ )}
517
+ </button>
518
+ </div>
519
+ </nav>
520
+
521
+ {/* Mobile menu dropdown */}
522
+ {isMenuOpen && (
523
+ <div ref={mobileMenuRef} className="md:hidden bg-[var(--theme-bg-primary)] border-b border-[var(--theme-border-primary)] relative z-[80]">
524
+ <ul className="flex flex-col p-4 gap-4">
525
+ <li>
526
+ <button
527
+ type="button"
528
+ onClick={() => setIsUserMenuOpen((open) => !open)}
529
+ className="flex w-full items-center justify-between rounded-full border border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] p-2 text-[var(--theme-text-primary)] hover:border-[var(--color-primary)] transition-colors"
530
+ aria-label="Open user menu"
531
+ aria-expanded={isUserMenuOpen}
532
+ >
533
+ <span className="flex items-center gap-2">
534
+ {userImage ? (
535
+ <img
536
+ src={userImage}
537
+ alt={userName ? `${userName} avatar` : "User avatar"}
538
+ className="h-8 w-8 rounded-full object-cover"
539
+ />
540
+ ) : (
541
+ <span className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--theme-border-primary)] bg-[var(--theme-card-bg)] text-xs font-semibold text-[var(--theme-text-primary)]">
542
+ {userInitials}
543
+ </span>
544
+ )}
545
+ <span className="text-sm">
546
+ {userName || userEmail || "Signed in"}
547
+ </span>
548
+ </span>
549
+ <svg
550
+ width="14"
551
+ height="14"
552
+ viewBox="0 0 24 24"
553
+ fill="none"
554
+ stroke="currentColor"
555
+ strokeWidth="2"
556
+ strokeLinecap="round"
557
+ strokeLinejoin="round"
558
+ className="opacity-70"
559
+ aria-hidden="true"
560
+ >
561
+ <polyline points="6 9 12 15 18 9"></polyline>
562
+ </svg>
563
+ </button>
564
+ {isUserMenuOpen && (
565
+ <div className="mt-2 rounded-md border border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)]">
566
+ {teamList.length > 0 && (
567
+ <div className="border-b border-[var(--theme-border-primary)]">
568
+ <button
569
+ type="button"
570
+ className="flex w-full cursor-pointer items-center justify-between px-3 py-2 text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
571
+ onClick={() => setIsTeamMenuOpen((open) => !open)}
572
+ aria-haspopup="menu"
573
+ aria-expanded={isTeamMenuOpen}
574
+ >
575
+ Switch Team
576
+ <svg
577
+ width="14"
578
+ height="14"
579
+ viewBox="0 0 24 24"
580
+ fill="none"
581
+ stroke="currentColor"
582
+ strokeWidth="2"
583
+ strokeLinecap="round"
584
+ strokeLinejoin="round"
585
+ className={`opacity-60 transition-transform ${isTeamMenuOpen ? "rotate-180" : ""
586
+ }`}
587
+ aria-hidden="true"
588
+ >
589
+ <polyline points="6 9 12 15 18 9"></polyline>
590
+ </svg>
591
+ </button>
592
+ {isTeamMenuOpen && (
593
+ <div className="pb-2">
594
+ <div className="px-3 pt-2 text-[11px] uppercase tracking-wide text-[var(--theme-text-secondary)]">
595
+ Current team
596
+ </div>
597
+ {currentTeam ? renderTeamButton(currentTeam, true) : null}
598
+ {otherTeams.length > 0 && (
599
+ <div className="border-t border-[var(--theme-border-primary)] mt-1 pt-1">
600
+ <div className="px-3 pt-2 text-[11px] uppercase tracking-wide text-[var(--theme-text-secondary)]">
601
+ Other teams
602
+ </div>
603
+ {otherTeams.map((team) => renderTeamButton(team, false))}
604
+ </div>
605
+ )}
606
+ <div className="border-t border-[var(--theme-border-primary)] mt-1 pt-1">
607
+ <button
608
+ type="button"
609
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors disabled:cursor-not-allowed disabled:opacity-60"
610
+ onClick={openCreateTeamModal}
611
+ disabled={isCreatingTeam || switchingTeamId !== null}
612
+ >
613
+ <span className="flex h-5 w-5 items-center justify-center rounded-full border border-[var(--theme-border-primary)] text-xs">
614
+ +
615
+ </span>
616
+ Create a team
617
+ </button>
618
+ </div>
619
+ </div>
620
+ )}
621
+ </div>
622
+ )}
623
+ <Link
624
+ href="/dashboard/settings"
625
+ className="block cursor-pointer px-3 py-2 text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
626
+ onClick={() => {
627
+ setIsUserMenuOpen(false);
628
+ setIsMenuOpen(false);
629
+ }}
630
+ >
631
+ Settings
632
+ </Link>
633
+ <div className="flex items-center justify-between px-3 py-2 text-sm text-[var(--theme-text-primary)]">
634
+ <span>Theme</span>
635
+ <ThemeToggle />
636
+ </div>
637
+ <button
638
+ className="w-full cursor-pointer px-3 py-2 text-left text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
639
+ onClick={() => {
640
+ setIsUserMenuOpen(false);
641
+ setIsMenuOpen(false);
642
+ signOut();
643
+ }}
644
+ >
645
+ Sign Out
646
+ </button>
647
+ </div>
648
+ )}
649
+ </li>
650
+ <li>
651
+ <Link
652
+ href="/dashboard"
653
+ className="block text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
654
+ onClick={() => setIsMenuOpen(false)}
655
+ >
656
+ Overview
657
+ </Link>
658
+ </li>
659
+ <li>
660
+ <Link
661
+ href="/dashboard/events"
662
+ className="block text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
663
+ onClick={() => setIsMenuOpen(false)}
664
+ >
665
+ Events
666
+ </Link>
667
+ </li>
668
+ <li>
669
+ <Link
670
+ href="/dashboard/explore"
671
+ className="block text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
672
+ onClick={() => setIsMenuOpen(false)}
673
+ >
674
+ Explore
675
+ </Link>
676
+ </li>
677
+ </ul>
678
+ </div>
679
+ )}
680
+ {isCreateTeamOpen && (
681
+ <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 px-4">
682
+ <div className="w-[min(32rem,calc(100vw-2rem))] rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] p-6 shadow-xl">
683
+ <div className="flex items-start justify-between gap-4">
684
+ <div className="max-w-[22rem]">
685
+ <h2 className="text-lg font-semibold text-[var(--theme-text-primary)]">
686
+ Create a new team
687
+ </h2>
688
+ <p className="mt-1 text-sm text-[var(--theme-text-secondary)]">
689
+ Teams are shared spaces for projects, sites, and reports.
690
+ </p>
691
+ </div>
692
+ <button
693
+ type="button"
694
+ className="rounded-full border border-[var(--theme-border-primary)] p-1 text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)] transition-colors"
695
+ onClick={closeCreateTeamModal}
696
+ aria-label="Close"
697
+ >
698
+ <svg
699
+ width="16"
700
+ height="16"
701
+ viewBox="0 0 24 24"
702
+ fill="none"
703
+ stroke="currentColor"
704
+ strokeWidth="2"
705
+ strokeLinecap="round"
706
+ strokeLinejoin="round"
707
+ aria-hidden="true"
708
+ >
709
+ <line x1="18" y1="6" x2="6" y2="18"></line>
710
+ <line x1="6" y1="6" x2="18" y2="18"></line>
711
+ </svg>
712
+ </button>
713
+ </div>
714
+ <form onSubmit={handleCreateTeam} className="mt-5 space-y-4">
715
+ <div>
716
+ <label className="block text-sm font-medium text-[var(--theme-text-primary)]">
717
+ Team name
718
+ </label>
719
+ <input
720
+ value={createTeamName}
721
+ onChange={(event) => setCreateTeamName(event.target.value)}
722
+ className="mt-2 w-full rounded-md border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] px-3 py-2 text-[var(--theme-text-primary)] focus:border-[var(--theme-border-primary)] focus:outline-none"
723
+ placeholder="e.g. Lytx Analytics"
724
+ maxLength={80}
725
+ autoFocus
726
+ />
727
+ </div>
728
+ {createTeamError ? (
729
+ <p className="text-sm text-red-500">{createTeamError}</p>
730
+ ) : null}
731
+ <div className="flex items-center justify-end gap-2">
732
+ <button
733
+ type="button"
734
+ className="rounded-md border border-[var(--theme-border-primary)] px-4 py-2 text-sm text-[var(--theme-text-primary)] hover:text-[var(--color-primary)] transition-colors"
735
+ onClick={closeCreateTeamModal}
736
+ >
737
+ Cancel
738
+ </button>
739
+ <button
740
+ type="submit"
741
+ className="rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-70"
742
+ disabled={
743
+ isCreatingTeam || switchingTeamId !== null || !createTeamName.trim()
744
+ }
745
+ >
746
+ {isCreatingTeam ? "Creating..." : "Create team"}
747
+ </button>
748
+ </div>
749
+ </form>
750
+ </div>
751
+ </div>
752
+ )}
753
+ </>
754
+ );
755
+ }