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,246 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Link } from "./Link";
5
+
6
+ export type ReportBuilderActionId =
7
+ | "create-report"
8
+ | "create-reference"
9
+ | "ask-ai"
10
+ | "create-dashboard"
11
+ | "create-notification";
12
+
13
+ export type ReportBuilderMenuActiveId = ReportBuilderActionId | `custom-report:${string}`;
14
+
15
+ export type ReportBuilderMenuItem = {
16
+ id: string;
17
+ label: string;
18
+ href?: string;
19
+ onSelect?: () => void;
20
+ };
21
+
22
+ export const defaultReportBuilderMenuItems: ReportBuilderMenuItem[] = [
23
+ { id: "create-report", label: "Create report" },
24
+ { id: "create-reference", label: "Create reference" },
25
+ { id: "ask-ai", label: "Ask AI" },
26
+ { id: "create-notification", label: "Create notification rule" },
27
+ ];
28
+
29
+ const ReportBuilderItemIcon = ({ itemId }: { itemId: string }) => {
30
+ if (itemId === "create-report") {
31
+ return (
32
+ <svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
33
+ <path d="M12 5v14" />
34
+ <path d="M5 12h14" />
35
+ </svg>
36
+ );
37
+ }
38
+
39
+ if (itemId === "create-reference") {
40
+ return (
41
+ <svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
42
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
43
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5V4.5A2.5 2.5 0 0 1 6.5 2Z" />
44
+ </svg>
45
+ );
46
+ }
47
+
48
+ if (itemId === "ask-ai") {
49
+ return (
50
+ <svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
51
+ <path d="m12 3 1.9 3.9L18 8.8l-3 2.9.7 4.1-3.7-2-3.7 2 .7-4.1-3-2.9 4.1-1.9L12 3Z" />
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ if (itemId === "create-dashboard") {
57
+ return (
58
+ <svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
59
+ <rect x="3" y="3" width="7" height="7" rx="1" />
60
+ <rect x="14" y="3" width="7" height="7" rx="1" />
61
+ <rect x="14" y="14" width="7" height="7" rx="1" />
62
+ <rect x="3" y="14" width="7" height="7" rx="1" />
63
+ </svg>
64
+ );
65
+ }
66
+
67
+ if (itemId.startsWith("custom-report:")) {
68
+ return (
69
+ <svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
70
+ <path d="M4 20h16" />
71
+ <path d="M7 16V9" />
72
+ <path d="M12 16V5" />
73
+ <path d="M17 16v-3" />
74
+ </svg>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
80
+ <path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5" />
81
+ <path d="M9 17a3 3 0 0 0 6 0" />
82
+ </svg>
83
+ );
84
+ };
85
+
86
+ type ReportBuilderMenuProps = {
87
+ items?: ReportBuilderMenuItem[];
88
+ activeItemId?: ReportBuilderMenuActiveId;
89
+ buttonLabel?: string;
90
+ onItemSelect?: (item: ReportBuilderMenuItem) => void;
91
+ };
92
+
93
+ const menuItemBaseClass = "w-full text-left px-3 py-2 text-sm text-(--theme-text-primary) transition-colors flex items-center gap-2";
94
+
95
+ export function ReportBuilderMenu({
96
+ items = defaultReportBuilderMenuItems,
97
+ activeItemId,
98
+ buttonLabel = "Create report",
99
+ onItemSelect,
100
+ }: ReportBuilderMenuProps) {
101
+ const [isOpen, setIsOpen] = useState(false);
102
+ const [searchValue, setSearchValue] = useState("");
103
+ const menuRef = useRef<HTMLDivElement>(null);
104
+ const searchInputRef = useRef<HTMLInputElement>(null);
105
+
106
+ const createReportItem = items.find((item) => item.id === "create-report");
107
+ const filteredItems = items
108
+ .filter((item) => item.id !== "create-report")
109
+ .filter((item) => item.label.toLowerCase().includes(searchValue.trim().toLowerCase()));
110
+ const activeItem = items.find((item) => item.id === activeItemId);
111
+ const resolvedButtonLabel = activeItem?.label ?? buttonLabel;
112
+
113
+ const handleItemSelect = (item: ReportBuilderMenuItem) => {
114
+ item.onSelect?.();
115
+ onItemSelect?.(item);
116
+ setIsOpen(false);
117
+ setSearchValue("");
118
+ };
119
+
120
+ useEffect(() => {
121
+ if (!isOpen) return;
122
+
123
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
124
+ const target = event.target;
125
+ if (!(target instanceof Node)) return;
126
+ if (!menuRef.current?.contains(target)) {
127
+ setIsOpen(false);
128
+ }
129
+ };
130
+
131
+ const handleKeyDown = (event: KeyboardEvent) => {
132
+ if (event.key === "Escape") {
133
+ setIsOpen(false);
134
+ }
135
+ };
136
+
137
+ document.addEventListener("mousedown", handlePointerDown);
138
+ document.addEventListener("touchstart", handlePointerDown);
139
+ document.addEventListener("keydown", handleKeyDown);
140
+
141
+ return () => {
142
+ document.removeEventListener("mousedown", handlePointerDown);
143
+ document.removeEventListener("touchstart", handlePointerDown);
144
+ document.removeEventListener("keydown", handleKeyDown);
145
+ };
146
+ }, [isOpen]);
147
+
148
+ useEffect(() => {
149
+ if (!isOpen) return;
150
+ const timeoutId = window.setTimeout(() => {
151
+ searchInputRef.current?.focus();
152
+ }, 0);
153
+ return () => {
154
+ window.clearTimeout(timeoutId);
155
+ };
156
+ }, [isOpen]);
157
+
158
+ const renderMenuItem = (item: ReportBuilderMenuItem) => {
159
+ const isActive = activeItemId === item.id;
160
+ const itemClass = `${menuItemBaseClass} ${isActive ? "bg-(--theme-bg-secondary)" : "hover:bg-(--theme-bg-secondary)"}`;
161
+
162
+ if (item.href) {
163
+ return (
164
+ <Link
165
+ key={item.id}
166
+ href={item.href}
167
+ role="menuitem"
168
+ onClick={() => handleItemSelect(item)}
169
+ className={itemClass}
170
+ >
171
+ <span className="inline-flex h-5 w-5 items-center justify-center rounded text-(--theme-text-secondary)">
172
+ <ReportBuilderItemIcon itemId={item.id} />
173
+ </span>
174
+ <span className="truncate">{item.label}</span>
175
+ </Link>
176
+ );
177
+ }
178
+
179
+ return (
180
+ <button
181
+ key={item.id}
182
+ type="button"
183
+ role="menuitem"
184
+ onClick={() => handleItemSelect(item)}
185
+ className={itemClass}
186
+ >
187
+ <span className="inline-flex h-5 w-5 items-center justify-center rounded text-(--theme-text-secondary)">
188
+ <ReportBuilderItemIcon itemId={item.id} />
189
+ </span>
190
+ <span className="truncate">{item.label}</span>
191
+ </button>
192
+ );
193
+ };
194
+
195
+ return (
196
+ <div className="relative" ref={menuRef}>
197
+ <button
198
+ type="button"
199
+ onClick={() => setIsOpen((open) => !open)}
200
+ aria-haspopup="menu"
201
+ aria-expanded={isOpen}
202
+ aria-controls="create-report-menu"
203
+ className="bg-(--theme-bg-secondary) hover:bg-(--theme-bg-tertiary) text-(--theme-text-primary) font-medium h-9 sm:h-auto px-2.5 sm:py-2 sm:px-4 text-sm rounded-md border border-(--theme-border-primary) transition-colors flex items-center gap-1.5 sm:gap-2 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
204
+ >
205
+ <span className="sm:hidden max-w-24 truncate">{resolvedButtonLabel}</span>
206
+ <span className="hidden sm:inline max-w-48 truncate">{resolvedButtonLabel}</span>
207
+ <svg aria-hidden="true" className="h-4 w-4 shrink-0 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
208
+ <path d="m6 9 6 6 6-6" />
209
+ </svg>
210
+ </button>
211
+ {isOpen && (
212
+ <div
213
+ id="create-report-menu"
214
+ role="menu"
215
+ className="absolute right-0 mt-1 w-72 rounded-md border border-(--theme-border-primary) bg-(--theme-bg-primary) shadow-lg z-[70]"
216
+ >
217
+ {createReportItem ? (
218
+ <div className="sticky top-0 z-10 border-b border-(--theme-border-primary) bg-(--theme-bg-primary) py-1">
219
+ {renderMenuItem(createReportItem)}
220
+ </div>
221
+ ) : null}
222
+
223
+ <div className="border-b border-(--theme-border-primary) px-3 py-2">
224
+ <input
225
+ ref={searchInputRef}
226
+ type="text"
227
+ value={searchValue}
228
+ onChange={(event) => setSearchValue(event.target.value)}
229
+ placeholder="Search reports..."
230
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
231
+ aria-label="Search reports"
232
+ />
233
+ </div>
234
+
235
+ <div className="max-h-72 overflow-y-auto py-1">
236
+ {filteredItems.length === 0 ? (
237
+ <p className="px-3 py-2 text-xs text-(--theme-text-secondary)">No matching reports.</p>
238
+ ) : (
239
+ filteredItems.map((item) => renderMenuItem(item))
240
+ )}
241
+ </div>
242
+ </div>
243
+ )}
244
+ </div>
245
+ );
246
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import React from "react";
3
+ import { useTheme } from "@/app/providers/ThemeProvider";
4
+
5
+ export const ThemeToggle: React.FC = () => {
6
+ const { theme, toggleTheme, isInitialized } = useTheme();
7
+
8
+ if (!isInitialized) {
9
+ return (
10
+ <div className="p-2 w-9 h-9 rounded-lg bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] animate-pulse">
11
+ <div className="w-5 h-5 bg-[var(--theme-text-secondary)] opacity-30 rounded"></div>
12
+ </div>
13
+ );
14
+ }
15
+
16
+ return (
17
+ <button
18
+ onClick={toggleTheme}
19
+ className="p-2 rounded-lg bg-[var(--theme-bg-secondary)] hover:bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-primary)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--theme-border-secondary)]"
20
+ aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
21
+ title={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
22
+ >
23
+ {theme === "light" ? (
24
+ <svg
25
+ className="w-5 h-5 text-[var(--theme-text-primary)]"
26
+ fill="none"
27
+ stroke="currentColor"
28
+ viewBox="0 0 24 24"
29
+ >
30
+ <path
31
+ strokeLinecap="round"
32
+ strokeLinejoin="round"
33
+ strokeWidth={2}
34
+ d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
35
+ />
36
+ </svg>
37
+ ) : (
38
+ <svg
39
+ className="w-5 h-5 text-[var(--theme-text-primary)]"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ viewBox="0 0 24 24"
43
+ >
44
+ <path
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ strokeWidth={2}
48
+ d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
49
+ />
50
+ </svg>
51
+ )}
52
+ </button>
53
+ );
54
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Shared constants for the Lytx application
3
+ */
4
+
5
+ /** The path to the Lytx tracking script */
6
+ export const LYTX_SCRIPT_PATH = "/lytx.v2.js";
@@ -0,0 +1,33 @@
1
+ import { RouteMiddleware } from "rwsdk/router";
2
+ import { IS_DEV } from "rwsdk/constants";
3
+
4
+ export const setCommonHeaders =
5
+ (): RouteMiddleware =>
6
+ ({ response, rw: { nonce } }) => {
7
+ const headers = response.headers;
8
+ if (!IS_DEV) {
9
+ // Forces browsers to always use HTTPS for a specified time period (2 years)
10
+ headers.set(
11
+ "Strict-Transport-Security",
12
+ "max-age=63072000; includeSubDomains; preload",
13
+ );
14
+ }
15
+
16
+ // Forces browser to use the declared content-type instead of trying to guess/sniff it
17
+ headers.set("X-Content-Type-Options", "nosniff");
18
+
19
+ // Stops browsers from sending the referring webpage URL in HTTP headers
20
+ headers.set("Referrer-Policy", "no-referrer");
21
+
22
+ // Explicitly disables access to specific browser features/APIs
23
+ headers.set(
24
+ "Permissions-Policy",
25
+ "geolocation=(), microphone=(), camera=()",
26
+ );
27
+
28
+ // Defines trusted sources for content loading and script execution:
29
+ headers.set(
30
+ "Content-Security-Policy",
31
+ `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; object-src 'none';`,
32
+ );
33
+ };
@@ -0,0 +1,189 @@
1
+ "use client";
2
+ import { createContext, useEffect, useRef, useState } from "react";
3
+ import { createAuthClient } from "better-auth/react";
4
+ import { customSessionClient } from "better-auth/client/plugins";
5
+ import type { AuthUserSession } from "@lib/auth";
6
+ import { getLastSiteFromStorage } from "@/app/components/SiteSelector";
7
+ export const authClient = createAuthClient({
8
+ plugins: [customSessionClient()],
9
+ });
10
+ // type Provider = Params<ReturnType<typeof authClient.signIn.social>>["provider"]
11
+ type UserData = AuthUserSession | null;
12
+ export const emailSignUp = async (
13
+ email: string,
14
+ password: string,
15
+ name: string,
16
+ ) => {
17
+ const result = await authClient.signUp.email({
18
+ email: email,
19
+ password: password,
20
+ name: name,
21
+ // callbackURL: "/dashboard"
22
+ });
23
+
24
+ if (result.error) {
25
+ throw new Error(result.error.message || "Sign up failed");
26
+ }
27
+ };
28
+
29
+ export const signIn = async (
30
+ provider: "github" | "google" | "email",
31
+ emailValues?: { email: string; password: string },
32
+ ) => {
33
+ if (provider === "email" && emailValues) {
34
+ const { email, password } = emailValues;
35
+ const result = await authClient.signIn.email({
36
+ email: email,
37
+ password: password,
38
+ callbackURL: "/dashboard",
39
+ });
40
+ if (result.error) {
41
+ throw new Error(result.error.message || "Sign in failed");
42
+ }
43
+ } else {
44
+ await authClient.signIn.social({
45
+ provider: provider,
46
+ callbackURL: "/dashboard",
47
+ });
48
+ }
49
+ };
50
+
51
+ export const signOut = async () => {
52
+ await authClient.signOut();
53
+ window.location.reload();
54
+ };
55
+
56
+ export const resendVerificationEmail = async (email: string) => {
57
+ const response = await fetch("/api/resend-verification-email", {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({ email, callbackURL: "/dashboard" }),
61
+ });
62
+
63
+ if (!response.ok) {
64
+ const body = (await response.json().catch(() => null)) as
65
+ | { error?: string; message?: string }
66
+ | null;
67
+ const message = body?.error || body?.message || response.statusText;
68
+ throw new Error(message);
69
+ }
70
+ };
71
+
72
+ export const AuthContext = createContext<{
73
+ data: UserData;
74
+ isPending: boolean;
75
+ error: unknown;
76
+ refetch: () => void | Promise<void>;
77
+ current_site: Currentsite;
78
+ setCurrentSite: (site: Currentsite) => void;
79
+ }>(null as unknown as {
80
+ data: UserData;
81
+ isPending: boolean;
82
+ error: unknown;
83
+ refetch: () => void | Promise<void>;
84
+ current_site: Currentsite;
85
+ setCurrentSite: (site: Currentsite) => void;
86
+ });
87
+
88
+ export type Currentsite = { name: string, id: number, tag_id: string } | null
89
+
90
+ export function AuthProvider({ children }: { children: React.ReactNode }) {
91
+ const sessionState = authClient.useSession();
92
+ const data = sessionState.data as AuthUserSession | null;
93
+ const { isPending, error, refetch } = sessionState;
94
+ const refetchFreshSession = async () => {
95
+ const sessionAtom = authClient.$store.atoms.session;
96
+ const current = sessionAtom.get();
97
+ sessionAtom.set({
98
+ ...current,
99
+ isPending: current.data === null,
100
+ isRefetching: true,
101
+ error: null,
102
+ refetch: current.refetch,
103
+ });
104
+
105
+ const response = await authClient.$fetch("/get-session", {
106
+ method: "GET",
107
+ query: { disableCookieCache: true },
108
+ });
109
+
110
+ if (response.error) {
111
+ sessionAtom.set({
112
+ data: null,
113
+ error: response.error,
114
+ isPending: false,
115
+ isRefetching: false,
116
+ refetch: current.refetch,
117
+ });
118
+ return;
119
+ }
120
+
121
+ sessionAtom.set({
122
+ data: response.data ?? null,
123
+ error: null,
124
+ isPending: false,
125
+ isRefetching: false,
126
+ refetch: current.refetch,
127
+ });
128
+ };
129
+ //Current Site ID
130
+ const [current_site, setCurrentSite] = useState<Currentsite>(null);
131
+
132
+ const hasInitialized = useRef(false);
133
+
134
+ useEffect(() => {
135
+ if (hasInitialized.current || current_site) return;
136
+ if (!data?.userSites?.length) return;
137
+
138
+ // Priority: localStorage > session.last_site_id > first site
139
+ let defaultSite = data.userSites[0];
140
+
141
+ // Check localStorage first (most recent selection)
142
+ const storedSiteId = getLastSiteFromStorage();
143
+ if (storedSiteId) {
144
+ const storedSite = data.userSites.find(site => site.site_id === storedSiteId);
145
+ if (storedSite) {
146
+ defaultSite = storedSite;
147
+ }
148
+ } else if (data.last_site_id) {
149
+ // Fall back to session data (from database)
150
+ const lastSite = data.userSites.find(site => site.site_id === data.last_site_id);
151
+ if (lastSite) {
152
+ defaultSite = lastSite;
153
+ }
154
+ }
155
+
156
+ if (!defaultSite) return;
157
+
158
+ hasInitialized.current = true;
159
+ setCurrentSite({
160
+ name: defaultSite.name!,
161
+ id: defaultSite.site_id,
162
+ tag_id: defaultSite.tag_id,
163
+ });
164
+ }, [current_site, data]);
165
+
166
+ //WARNING: Only using for dev
167
+ useEffect(() => {
168
+ if (import.meta.env.DEV) {
169
+ console.log("🔥🔥🔥 Effect for current_site has been triggered")
170
+ if (current_site) console.dir(current_site);
171
+ }
172
+ //Fetch to update session in better-auth
173
+ }, [current_site]);
174
+
175
+ return (
176
+ <AuthContext.Provider
177
+ value={{
178
+ data,
179
+ isPending,
180
+ error,
181
+ refetch: refetchFreshSession,
182
+ current_site,
183
+ setCurrentSite,
184
+ }}
185
+ >
186
+ {children}
187
+ </AuthContext.Provider>
188
+ );
189
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { ThemeProvider } from "@/app/providers/ThemeProvider";
3
+ import { AuthProvider } from "@/app/providers/AuthProvider";
4
+ import { AppQueryProvider } from "@/app/providers/QueryProvider";
5
+
6
+ interface ClientProvidersProps {
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export function ClientProviders({ children }: ClientProvidersProps) {
11
+ return (
12
+ <ThemeProvider>
13
+ <AuthProvider>
14
+ <AppQueryProvider>{children}</AppQueryProvider>
15
+ </AuthProvider>
16
+ </ThemeProvider>
17
+ );
18
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import React, { useState } from 'react';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ interface AppQueryProviderProps {
5
+ children: React.ReactNode;
6
+ }
7
+
8
+ export function AppQueryProvider({ children }: AppQueryProviderProps) {
9
+ const [queryClient] = useState(() => new QueryClient({
10
+ defaultOptions: {
11
+ queries: {
12
+ staleTime: 0,
13
+ gcTime: 0,
14
+ },
15
+ },
16
+ }));
17
+
18
+ return (
19
+ <QueryClientProvider client={queryClient}>
20
+ {children}
21
+ </QueryClientProvider>
22
+ );
23
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+ import React, { createContext, useCallback, useEffect, useState } from "react";
3
+
4
+ export type Theme = "light" | "dark";
5
+
6
+ interface ThemeContextType {
7
+ theme: "light" | "dark";
8
+ isInitialized: boolean;
9
+ toggleTheme: () => void;
10
+ }
11
+
12
+ export const ThemeContext = createContext<ThemeContextType | null>(null);
13
+
14
+ export const useTheme = () => {
15
+ const context = React.useContext(ThemeContext);
16
+ if (!context) {
17
+ throw new Error("useTheme must be used within a ThemeProvider");
18
+ }
19
+ return context;
20
+ };
21
+
22
+ interface ThemeProviderProps {
23
+ children: React.ReactNode;
24
+ // defaultTheme?: Theme;
25
+ }
26
+
27
+ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
28
+ const [theme, setThemeState] = useState<Theme>("dark");
29
+ const [isInitialized, setIsInitialized] = useState(false);
30
+ useEffect(() => {
31
+ if (typeof window !== "undefined") {
32
+ const savedTheme = localStorage.getItem("theme") as Theme;
33
+ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
34
+ .matches
35
+ ? "dark"
36
+ : "light";
37
+ const initialTheme = savedTheme || systemTheme;
38
+
39
+ setThemeState(initialTheme);
40
+ setIsInitialized(true);
41
+ }
42
+ }, []);
43
+ useEffect(() => {
44
+ if (isInitialized && typeof window !== "undefined") {
45
+ const root = document.documentElement;
46
+
47
+ // Temporarily enable transitions for theme changes only.
48
+ root.classList.add("theme-transition");
49
+ const timeout = window.setTimeout(() => {
50
+ root.classList.remove("theme-transition");
51
+ }, 250);
52
+
53
+ root.setAttribute("data-theme", theme);
54
+ localStorage.setItem("theme", theme);
55
+
56
+ return () => {
57
+ window.clearTimeout(timeout);
58
+ root.classList.remove("theme-transition");
59
+ };
60
+ }
61
+ }, [theme, isInitialized]);
62
+
63
+ const toggleTheme = useCallback(() => {
64
+ setThemeState((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
65
+ }, []);
66
+
67
+ // // Memoize the context value
68
+ // const contextValue: ThemeContextType = useMemo(
69
+ // () => ({
70
+ // theme,
71
+ // isInitialized,
72
+ // toggleTheme,
73
+ // }),
74
+ // [theme, isInitialized, toggleTheme],
75
+ // );
76
+
77
+ const contextValue = {
78
+ theme,
79
+ isInitialized,
80
+ toggleTheme,
81
+ };
82
+
83
+ return (
84
+ <ThemeContext.Provider value={contextValue}>
85
+ {children}
86
+ </ThemeContext.Provider>
87
+ );
88
+ };