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,371 @@
1
+ "use client";
2
+
3
+ import { useContext, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useChat } from "@ai-sdk/react";
5
+ import { DefaultChatTransport } from "ai";
6
+ import { ResponsiveBar } from "@nivo/bar";
7
+ import { ResponsiveLine } from "@nivo/line";
8
+ import { ResponsivePie } from "@nivo/pie";
9
+ import { Button } from "@/app/components/ui/Button";
10
+ import { AuthContext } from "@/app/providers/AuthProvider";
11
+ import { useTheme } from "@/app/providers/ThemeProvider";
12
+ import { createChartTheme } from "@/app/utils/chartThemes";
13
+
14
+ type AskAiWorkspaceProps = {
15
+ initialAiConfigured: boolean;
16
+ initialAiModel: string;
17
+ };
18
+
19
+ type AiChartPoint = {
20
+ x: string;
21
+ y: number;
22
+ };
23
+
24
+ type AiNivoChartOutput = {
25
+ kind: "nivo-chart";
26
+ chartType: "bar" | "line" | "pie";
27
+ title: string;
28
+ metricType?: string;
29
+ siteId: number;
30
+ dateRange?: {
31
+ start?: string;
32
+ end?: string;
33
+ };
34
+ points: AiChartPoint[];
35
+ };
36
+
37
+ const truncateAxisLabel = (value: unknown, max = 20) => {
38
+ const label = String(value ?? "").trim();
39
+ if (label.length <= max) return label;
40
+ return `${label.slice(0, max - 3)}...`;
41
+ };
42
+
43
+ function isAiNivoChartOutput(value: unknown): value is AiNivoChartOutput {
44
+ if (!value || typeof value !== "object") return false;
45
+ const candidate = value as Partial<AiNivoChartOutput>;
46
+ const chartType = candidate.chartType;
47
+ return candidate.kind === "nivo-chart"
48
+ && (chartType === "bar" || chartType === "line" || chartType === "pie")
49
+ && Array.isArray(candidate.points);
50
+ }
51
+
52
+ function getMessageText(parts: Array<unknown>) {
53
+ return parts
54
+ .map((part) => {
55
+ if (!part || typeof part !== "object") return "";
56
+ const candidate = part as { type?: string; text?: string };
57
+ return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
58
+ })
59
+ .join("");
60
+ }
61
+
62
+ function getChartOutput(parts: Array<unknown>): AiNivoChartOutput | null {
63
+ for (const part of parts) {
64
+ if (!part || typeof part !== "object") continue;
65
+ const candidate = part as { state?: string; output?: unknown };
66
+ if (candidate.state !== "output-available") continue;
67
+ if (isAiNivoChartOutput(candidate.output)) {
68
+ return candidate.output;
69
+ }
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ function getChartFallbackSummary(chart: AiNivoChartOutput) {
76
+ const points = chart.points
77
+ .map((point) => ({ x: String(point.x ?? "Unknown"), y: Number(point.y) || 0 }))
78
+ .filter((point) => point.x.length > 0)
79
+ .toSorted((a, b) => b.y - a.y);
80
+
81
+ if (points.length === 0) {
82
+ return `Here is your ${chart.chartType} chart for ${chart.title}. There is no data in the selected range.`;
83
+ }
84
+
85
+ const top = points.slice(0, 3).map((point) => `${point.x} (${point.y})`).join(", ");
86
+ return `Here is your ${chart.chartType} chart for ${chart.title}. Top values: ${top}.`;
87
+ }
88
+
89
+ const starterPrompts = [
90
+ "Show a bar chart of top pages in the last 14 days",
91
+ "Make a bar chart of top referrers for the last 7 days",
92
+ "Create a line chart of daily event volume for the last 30 days",
93
+ "Plot a line chart of hourly events for the last 24 hours",
94
+ "Show a pie chart of device types for this week",
95
+ "Give me a pie chart of country distribution for today",
96
+ ];
97
+
98
+ function AskAiChartPanel({
99
+ chart,
100
+ chartTheme,
101
+ legendTextColor,
102
+ }: {
103
+ chart: AiNivoChartOutput;
104
+ chartTheme: ReturnType<typeof createChartTheme>;
105
+ legendTextColor: string;
106
+ }) {
107
+ const points = chart.points
108
+ .map((point) => ({ x: String(point.x ?? "Unknown"), y: Number(point.y) || 0 }))
109
+ .filter((point) => point.x.length > 0);
110
+
111
+ return (
112
+ <div className="w-full rounded-lg border border-(--theme-border-primary) bg-(--theme-bg-primary) p-3">
113
+ <div className="mb-2 text-sm font-medium text-(--theme-text-primary)">{chart.title}</div>
114
+ <div style={{ height: 260 }}>
115
+ {points.length === 0 ? (
116
+ <div className="flex h-full items-center justify-center text-sm text-(--theme-text-secondary)">
117
+ No chart data for this range.
118
+ </div>
119
+ ) : chart.chartType === "line" ? (
120
+ <ResponsiveLine
121
+ data={[
122
+ {
123
+ id: chart.metricType || chart.title,
124
+ data: points.map((point) => ({ x: point.x, y: point.y })),
125
+ },
126
+ ]}
127
+ margin={{ top: 20, right: 24, bottom: 44, left: 56 }}
128
+ xScale={{ type: "point" }}
129
+ yScale={{ type: "linear", min: 0, max: "auto", stacked: false, reverse: false }}
130
+ pointSize={7}
131
+ pointBorderWidth={2}
132
+ enableArea
133
+ areaOpacity={0.2}
134
+ useMesh
135
+ colors={["#f59e0b"]}
136
+ theme={chartTheme}
137
+ />
138
+ ) : chart.chartType === "pie" ? (
139
+ <ResponsivePie
140
+ data={points.map((point) => ({ id: point.x, label: point.x, value: point.y }))}
141
+ margin={{ top: 20, right: 24, bottom: 44, left: 24 }}
142
+ innerRadius={0.5}
143
+ padAngle={0.7}
144
+ cornerRadius={3}
145
+ activeOuterRadiusOffset={8}
146
+ colors={["#f59e0b", "#f97316", "#fb923c", "#fdba74", "#fcd34d", "#fbbf24"]}
147
+ theme={chartTheme}
148
+ legends={[
149
+ {
150
+ anchor: "bottom",
151
+ direction: "row",
152
+ justify: false,
153
+ translateY: 36,
154
+ itemWidth: 90,
155
+ itemHeight: 18,
156
+ itemsSpacing: 4,
157
+ symbolSize: 12,
158
+ symbolShape: "circle",
159
+ itemTextColor: legendTextColor,
160
+ },
161
+ ]}
162
+ />
163
+ ) : (
164
+ <ResponsiveBar
165
+ data={points.map((point) => ({ x: point.x, y: point.y }))}
166
+ keys={["y"]}
167
+ indexBy="x"
168
+ margin={{ top: 20, right: 24, bottom: 56, left: 72 }}
169
+ padding={0.3}
170
+ colors={["#f59e0b"]}
171
+ valueScale={{ type: "linear" }}
172
+ indexScale={{ type: "band", round: true }}
173
+ axisBottom={{
174
+ tickRotation: -20,
175
+ tickPadding: 14,
176
+ format: (value) => truncateAxisLabel(value),
177
+ }}
178
+ theme={chartTheme}
179
+ />
180
+ )}
181
+ </div>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export function AskAiWorkspace({ initialAiConfigured, initialAiModel }: AskAiWorkspaceProps) {
187
+ const { current_site } = useContext(AuthContext) || { current_site: null };
188
+ const { theme } = useTheme();
189
+ const chartTheme = useMemo(() => createChartTheme(theme === "dark"), [theme]);
190
+ const legendTextColor = theme === "dark" ? "#ffffff" : "#4b5563";
191
+ const chatTransport = useMemo(
192
+ () =>
193
+ new DefaultChatTransport({
194
+ api: "/api/ai/chat",
195
+ body: {
196
+ site_id: current_site?.id ?? null,
197
+ },
198
+ }),
199
+ [current_site?.id],
200
+ );
201
+
202
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
203
+ const aiConfigured = initialAiConfigured;
204
+
205
+ const {
206
+ messages,
207
+ status,
208
+ error,
209
+ sendMessage,
210
+ setMessages,
211
+ clearError,
212
+ } = useChat({
213
+ transport: chatTransport,
214
+ experimental_throttle: 24,
215
+ });
216
+
217
+ const isBusy = status === "submitted" || status === "streaming";
218
+ const canSend = aiConfigured && !isBusy;
219
+ const [draft, setDraft] = useState("");
220
+ const draftValue = typeof draft === "string" ? draft : "";
221
+ const modelLabel = aiConfigured
222
+ ? (initialAiModel || "Configured model")
223
+ : "Model not configured";
224
+
225
+ useEffect(() => {
226
+ const container = messagesContainerRef.current;
227
+ if (!container) return;
228
+
229
+ const frame = window.requestAnimationFrame(() => {
230
+ container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
231
+ });
232
+
233
+ return () => window.cancelAnimationFrame(frame);
234
+ }, [messages, status, error]);
235
+
236
+ return (
237
+ <section className="w-full min-h-[calc(100dvh-9rem)]">
238
+ <div className="flex h-[calc(100dvh-11rem)] min-h-[560px] flex-col rounded-xl border border-(--theme-border-primary) bg-(--theme-card-bg) p-4 sm:p-6">
239
+ <div className="flex items-center justify-between gap-3">
240
+ <div>
241
+ <h2 className="text-xl font-semibold text-(--theme-text-primary)">Ask AI</h2>
242
+ <p className="mt-1 text-sm text-(--theme-text-secondary)">
243
+ Quick conversational help for analytics questions and report ideas.
244
+ </p>
245
+ </div>
246
+ <div className="flex items-center gap-2">
247
+ <span className="hidden sm:inline-flex rounded-full border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2.5 py-1 text-xs text-(--theme-text-secondary)">
248
+ {modelLabel}
249
+ </span>
250
+ <Button
251
+ variant="secondary"
252
+ size="sm"
253
+ onClick={() => {
254
+ setMessages([]);
255
+ clearError();
256
+ }}
257
+ disabled={messages.length === 0 && !error}
258
+ >
259
+ Clear
260
+ </Button>
261
+ </div>
262
+ </div>
263
+
264
+ {!aiConfigured ? (
265
+ <div className="mt-4 rounded-lg border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-red-400">
266
+ AI is not configured on the server yet.
267
+ </div>
268
+ ) : null}
269
+
270
+ <div className="mt-4 flex min-h-0 flex-1 flex-col rounded-lg border border-(--theme-border-primary) bg-(--theme-bg-secondary) p-3">
271
+ <div ref={messagesContainerRef} className="min-h-0 flex-1 overflow-y-auto space-y-3 pr-1">
272
+ {messages.length === 0 ? (
273
+ <div className="space-y-3">
274
+ <p className="text-sm text-(--theme-text-secondary)">
275
+ Ask a question or use a starter prompt below.
276
+ </p>
277
+ </div>
278
+ ) : (
279
+ messages.map((message) => {
280
+ const isUser = message.role === "user";
281
+ const text = getMessageText(message.parts as Array<unknown>);
282
+ const chart = getChartOutput(message.parts as Array<unknown>);
283
+
284
+ if (text.trim().length === 0 && !chart) {
285
+ return null;
286
+ }
287
+
288
+ if (isUser) {
289
+ return (
290
+ <div
291
+ key={message.id}
292
+ className="ml-auto max-w-[92%] rounded-lg bg-(--theme-text-primary) px-3 py-2 text-sm whitespace-pre-wrap text-(--theme-bg-primary)"
293
+ >
294
+ {text}
295
+ </div>
296
+ );
297
+ }
298
+
299
+ return (
300
+ <div key={message.id} className="mr-auto w-full space-y-2">
301
+ {text.trim().length > 0 ? (
302
+ <div className="max-w-[92%] rounded-lg border border-(--theme-border-primary) bg-(--theme-bg-primary) px-3 py-2 text-sm whitespace-pre-wrap text-(--theme-text-primary)">
303
+ {text}
304
+ </div>
305
+ ) : chart ? (
306
+ <div className="max-w-[92%] rounded-lg border border-(--theme-border-primary) bg-(--theme-bg-primary) px-3 py-2 text-sm whitespace-pre-wrap text-(--theme-text-primary)">
307
+ {getChartFallbackSummary(chart)}
308
+ </div>
309
+ ) : null}
310
+ {chart ? (
311
+ <AskAiChartPanel
312
+ chart={chart}
313
+ chartTheme={chartTheme}
314
+ legendTextColor={legendTextColor}
315
+ />
316
+ ) : null}
317
+ </div>
318
+ );
319
+ })
320
+ )}
321
+ {error ? <p className="text-sm text-red-400">{error.message}</p> : null}
322
+ </div>
323
+
324
+ {messages.length === 0 ? (
325
+ <div className="mt-3 flex flex-wrap gap-2">
326
+ {starterPrompts.map((prompt) => (
327
+ <button
328
+ key={prompt}
329
+ type="button"
330
+ className="rounded-full border border-(--theme-border-primary) bg-(--theme-bg-primary) px-3 py-1.5 text-xs text-(--theme-text-primary) hover:bg-(--theme-bg-tertiary)"
331
+ onClick={() => setDraft(prompt)}
332
+ >
333
+ {prompt}
334
+ </button>
335
+ ))}
336
+ </div>
337
+ ) : null}
338
+
339
+ <form
340
+ className="mt-3 flex items-center gap-2"
341
+ onSubmit={(event) => {
342
+ event.preventDefault();
343
+ if (!canSend) {
344
+ return;
345
+ }
346
+ const nextMessage = draftValue.trim();
347
+ if (!nextMessage) return;
348
+ clearError();
349
+ void sendMessage({ text: nextMessage });
350
+ setDraft("");
351
+ }}
352
+ >
353
+ <input
354
+ value={draftValue}
355
+ onChange={(event) => {
356
+ if (error) clearError();
357
+ setDraft(event.target.value);
358
+ }}
359
+ placeholder={aiConfigured ? "Ask about your data..." : "AI not configured"}
360
+ disabled={!aiConfigured || isBusy}
361
+ className="flex-1 rounded-md border border-(--theme-input-border) bg-(--theme-input-bg) px-3 py-2 text-sm text-(--theme-text-primary) focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
362
+ />
363
+ <Button type="submit" variant="primary" disabled={!canSend || draftValue.trim().length === 0}>
364
+ {isBusy ? "..." : "Send"}
365
+ </Button>
366
+ </form>
367
+ </div>
368
+ </div>
369
+ </section>
370
+ );
371
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ type ReportTemplateCard = {
4
+ id: string;
5
+ title: string;
6
+ description: string;
7
+ };
8
+
9
+ const reportTemplateCards: ReportTemplateCard[] = [
10
+ {
11
+ id: "ecomm-tracker",
12
+ title: "Ecomm Tracker",
13
+ description: "Monitor storefront traffic, product views, cart starts, and checkout conversion in one report.",
14
+ },
15
+ {
16
+ id: "marketing-leads",
17
+ title: "Marketing leads",
18
+ description: "Track landing-page engagement, lead-form submissions, and channel quality for campaign optimization.",
19
+ },
20
+ ];
21
+
22
+ type CreateReportStarterProps = {
23
+ onStartCustomReport: () => void;
24
+ onStartTemplate: (templateId: string) => void;
25
+ };
26
+
27
+ export function CreateReportStarter({ onStartCustomReport, onStartTemplate }: CreateReportStarterProps) {
28
+ return (
29
+ <section className="max-w-5xl mx-auto space-y-6">
30
+ <div className="space-y-2">
31
+ <h2 className="text-2xl sm:text-3xl font-semibold text-(--theme-text-primary)">
32
+ Start a report
33
+ </h2>
34
+ <p className="text-(--theme-text-secondary)">
35
+ Build a custom report from scratch or start from a saved template.
36
+ </p>
37
+ </div>
38
+
39
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
40
+ <button
41
+ type="button"
42
+ onClick={onStartCustomReport}
43
+ className="group rounded-xl border border-dashed border-(--theme-border-primary) bg-(--theme-bg-secondary) hover:bg-(--theme-bg-tertiary) p-5 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
44
+ >
45
+ <span className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-(--theme-border-primary) text-(--theme-text-primary) mb-4">
46
+ <svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
47
+ <path d="M12 5v14" />
48
+ <path d="M5 12h14" />
49
+ </svg>
50
+ </span>
51
+ <p className="text-base font-semibold text-(--theme-text-primary)">Build custom report</p>
52
+ <p className="mt-1 text-sm text-(--theme-text-secondary)">
53
+ Start with a blank canvas and add the metrics and dimensions you need.
54
+ </p>
55
+ </button>
56
+
57
+ {reportTemplateCards.map((template) => (
58
+ <button
59
+ key={template.id}
60
+ type="button"
61
+ onClick={() => onStartTemplate(template.id)}
62
+ className="rounded-xl border border-(--theme-border-primary) bg-(--theme-card-bg) hover:bg-(--theme-bg-secondary) p-5 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-(--theme-border-secondary)"
63
+ >
64
+ <p className="text-base font-semibold text-(--theme-text-primary)">{template.title}</p>
65
+ <p className="mt-1 text-sm text-(--theme-text-secondary)">{template.description}</p>
66
+ <span className="mt-4 inline-flex items-center text-xs font-medium text-(--theme-text-secondary)">
67
+ Use template
68
+ </span>
69
+ </button>
70
+ ))}
71
+ </div>
72
+ </section>
73
+ );
74
+ }
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+ import type { DashboardFilters } from "@/app/components/charts/ChartComponents";
5
+
6
+ type DashboardRouteFiltersContextValue = {
7
+ filters: DashboardFilters;
8
+ timezone: string;
9
+ };
10
+
11
+ export const DashboardRouteFiltersContext =
12
+ createContext<DashboardRouteFiltersContextValue | null>(null);
13
+
14
+ export const useDashboardRouteFilters = () => useContext(DashboardRouteFiltersContext);
@@ -0,0 +1,154 @@
1
+ "use client";
2
+
3
+ import { useContext, useMemo } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import type { ReactNode } from "react";
6
+ import { SiteSelector } from "@components/SiteSelector";
7
+ import { AuthContext } from "@/app/providers/AuthProvider";
8
+ import { CurrentVisitors } from "@/app/components/charts/ChartComponents";
9
+ import {
10
+ ReportBuilderMenu,
11
+ type ReportBuilderMenuActiveId,
12
+ type ReportBuilderMenuItem,
13
+ } from "@/app/components/ui/ReportBuilderMenu";
14
+ import { getDashboardReportBuilderMenuItems } from "@/app/components/reports/reportBuilderMenuItems";
15
+ import type { CustomReportRecord } from "@/app/components/reports/custom/types";
16
+
17
+ export type ToolbarSiteOption = {
18
+ site_id: number;
19
+ name: string;
20
+ tag_id: string;
21
+ };
22
+
23
+ type DashboardToolbarProps = {
24
+ activeReportBuilderItemId?: ReportBuilderMenuActiveId;
25
+ reportBuilderEnabled?: boolean;
26
+ askAiEnabled?: boolean;
27
+ controls?: ReactNode;
28
+ footer?: ReactNode;
29
+ initialSites?: ToolbarSiteOption[];
30
+ initialSiteId?: number | null;
31
+ initialReportSwitcherItems?: ReportBuilderMenuItem[];
32
+ };
33
+
34
+ export function DashboardToolbar({
35
+ activeReportBuilderItemId = "create-report",
36
+ reportBuilderEnabled = false,
37
+ askAiEnabled = true,
38
+ controls,
39
+ footer,
40
+ initialSites = [],
41
+ initialSiteId = null,
42
+ initialReportSwitcherItems = [],
43
+ }: DashboardToolbarProps) {
44
+ const { data: session, current_site } = useContext(AuthContext) || { data: null, current_site: null };
45
+ const activeCustomReportUuid =
46
+ activeReportBuilderItemId.startsWith("custom-report:")
47
+ ? activeReportBuilderItemId.slice("custom-report:".length)
48
+ : null;
49
+
50
+ const fallbackSiteId = session?.userSites?.[0]?.site_id ?? initialSiteId ?? initialSites[0]?.site_id;
51
+ const currentSiteId = current_site?.id ?? fallbackSiteId;
52
+
53
+ const { data: customReportsData } = useQuery<{ reports?: CustomReportRecord[] }>({
54
+ queryKey: ["dashboard-toolbar-custom-reports", currentSiteId],
55
+ enabled: reportBuilderEnabled && !!currentSiteId,
56
+ queryFn: async () => {
57
+ const response = await fetch(`/api/reports/custom?site_id=${currentSiteId}`);
58
+ if (!response.ok) {
59
+ return { reports: [] };
60
+ }
61
+ return (await response.json()) as { reports?: CustomReportRecord[] };
62
+ },
63
+ staleTime: 0,
64
+ gcTime: 0,
65
+ });
66
+
67
+ const reportSwitcherItems = useMemo<ReportBuilderMenuItem[]>(() => {
68
+ const reports = customReportsData?.reports ?? [];
69
+ if (reports.length === 0 && initialReportSwitcherItems.length > 0) {
70
+ return initialReportSwitcherItems;
71
+ }
72
+
73
+ return reports.slice(0, 10).map((report) => ({
74
+ id: `custom-report:${report.uuid}`,
75
+ label: report.name || "Untitled custom report",
76
+ href: `/dashboard/reports/custom/${report.uuid}`,
77
+ }));
78
+ }, [customReportsData?.reports, initialReportSwitcherItems]);
79
+
80
+ const hasActiveCustomReportItem = activeCustomReportUuid
81
+ ? reportSwitcherItems.some((item) => item.id === `custom-report:${activeCustomReportUuid}`)
82
+ : false;
83
+
84
+ const { data: activeCustomReportData } = useQuery<{ report?: CustomReportRecord | null }>({
85
+ queryKey: ["dashboard-toolbar-active-custom-report", activeCustomReportUuid],
86
+ enabled: reportBuilderEnabled && Boolean(activeCustomReportUuid) && !hasActiveCustomReportItem,
87
+ queryFn: async () => {
88
+ const response = await fetch(`/api/reports/custom/${activeCustomReportUuid}`);
89
+ if (!response.ok) {
90
+ return { report: null };
91
+ }
92
+ return (await response.json()) as { report?: CustomReportRecord | null };
93
+ },
94
+ staleTime: 0,
95
+ gcTime: 0,
96
+ });
97
+
98
+ const resolvedReportSwitcherItems = useMemo<ReportBuilderMenuItem[]>(() => {
99
+ if (!activeCustomReportUuid || hasActiveCustomReportItem) {
100
+ return reportSwitcherItems;
101
+ }
102
+
103
+ return [
104
+ {
105
+ id: `custom-report:${activeCustomReportUuid}`,
106
+ label: activeCustomReportData?.report?.name || "Custom report",
107
+ href: `/dashboard/reports/custom/${activeCustomReportUuid}`,
108
+ },
109
+ ...reportSwitcherItems,
110
+ ];
111
+ }, [
112
+ activeCustomReportData?.report?.name,
113
+ activeCustomReportUuid,
114
+ hasActiveCustomReportItem,
115
+ reportSwitcherItems,
116
+ ]);
117
+
118
+ const reportBuilderMenuItems = useMemo<ReportBuilderMenuItem[]>(
119
+ () => [...getDashboardReportBuilderMenuItems({ askAiEnabled }), ...resolvedReportSwitcherItems],
120
+ [askAiEnabled, resolvedReportSwitcherItems],
121
+ );
122
+
123
+ return (
124
+ <div className="sticky top-0 z-40 bg-(--theme-bg-primary) border-t border-b border-(--theme-border-primary) px-3 py-2 sm:px-6 sm:py-3 lg:px-8 shadow-[0_6px_14px_rgba(0,0,0,0.12)]">
125
+ <div className="flex items-center justify-between gap-2 sm:gap-4">
126
+ <div className="flex items-center gap-2 sm:gap-3 min-w-0">
127
+ <SiteSelector initialSites={initialSites} initialSiteId={initialSiteId} />
128
+ {currentSiteId ? (
129
+ <div className="hidden sm:flex">
130
+ <CurrentVisitors siteId={currentSiteId} />
131
+ </div>
132
+ ) : null}
133
+ </div>
134
+ <div className="flex items-center gap-2 relative">
135
+ {reportBuilderEnabled ? (
136
+ <ReportBuilderMenu
137
+ items={reportBuilderMenuItems}
138
+ activeItemId={activeReportBuilderItemId}
139
+ />
140
+ ) : null}
141
+ {controls}
142
+ </div>
143
+ </div>
144
+
145
+ {currentSiteId ? (
146
+ <div className="flex sm:hidden items-center mt-1.5">
147
+ <CurrentVisitors siteId={currentSiteId} />
148
+ </div>
149
+ ) : null}
150
+
151
+ {footer}
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,63 @@
1
+ import type { LayoutProps } from "rwsdk/router";
2
+ import type { ReportBuilderMenuActiveId } from "@/app/components/ui/ReportBuilderMenu";
3
+ import { DashboardWorkspaceShell } from "@/app/components/reports/DashboardWorkspaceShell";
4
+ import { isAskAiEnabled, isReportBuilderEnabled } from "@/lib/featureFlags";
5
+ import type { ToolbarSiteOption } from "@/app/components/reports/DashboardToolbar";
6
+
7
+ type LayoutContextLike = {
8
+ session?: {
9
+ last_site_id?: number | null;
10
+ };
11
+ sites?: Array<{
12
+ site_id: number;
13
+ name: string | null;
14
+ tag_id: string;
15
+ }> | null;
16
+ };
17
+
18
+ const resolveActiveReportBuilderItem = (pathname: string): ReportBuilderMenuActiveId => {
19
+ const customReportMarker = "/dashboard/reports/custom/";
20
+ if (pathname.includes(customReportMarker)) {
21
+ const reportUuid = decodeURIComponent(pathname.slice(pathname.indexOf(customReportMarker) + customReportMarker.length));
22
+ if (reportUuid && reportUuid !== "new" && !reportUuid.includes("/")) {
23
+ return `custom-report:${reportUuid}`;
24
+ }
25
+ }
26
+
27
+ if (pathname.includes("/dashboard/reports/create-reference")) return "create-reference";
28
+ if (pathname.includes("/dashboard/reports/ask-ai")) return "ask-ai";
29
+ if (pathname.includes("/dashboard/reports/create-dashboard")) return "create-dashboard";
30
+ if (pathname.includes("/dashboard/reports/create-notification")) return "create-notification";
31
+ return "create-report";
32
+ };
33
+
34
+ export function DashboardWorkspaceLayout({ children, requestInfo }: LayoutProps) {
35
+ const pathname = requestInfo ? new URL(requestInfo.request.url).pathname : "/dashboard/reports/create-report";
36
+ const activeReportBuilderItemId = resolveActiveReportBuilderItem(pathname);
37
+ const reportBuilderEnabled = isReportBuilderEnabled();
38
+ const askAiEnabled = reportBuilderEnabled && isAskAiEnabled();
39
+ const ctx = requestInfo?.ctx as LayoutContextLike | undefined;
40
+
41
+ const initialSites: ToolbarSiteOption[] = (ctx?.sites ?? []).map((site) => ({
42
+ site_id: site.site_id,
43
+ name: site.name || `Site ${site.site_id}`,
44
+ tag_id: site.tag_id,
45
+ }));
46
+
47
+ const preferredSiteId = ctx?.session?.last_site_id ?? null;
48
+ const initialSiteId = initialSites.some((site) => site.site_id === preferredSiteId)
49
+ ? preferredSiteId
50
+ : (initialSites[0]?.site_id ?? null);
51
+
52
+ return (
53
+ <DashboardWorkspaceShell
54
+ activeReportBuilderItemId={activeReportBuilderItemId}
55
+ reportBuilderEnabled={reportBuilderEnabled}
56
+ askAiEnabled={askAiEnabled}
57
+ initialSites={initialSites}
58
+ initialSiteId={initialSiteId}
59
+ >
60
+ {children}
61
+ </DashboardWorkspaceShell>
62
+ );
63
+ }