create-analytics-demo 1.0.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.
@@ -0,0 +1,524 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { config } from "../demo.config";
3
+ import { MiniIPhoneFrame } from "../components/IPhoneFrame";
4
+ import { TravelingBall, type TravelingEvent } from "../components/TravelingBall";
5
+ import {
6
+ LiveCodePanel,
7
+ getEventCategory,
8
+ shouldGoToSecondary,
9
+ type AnalyticsEvent,
10
+ } from "../components/LiveCodePanel";
11
+
12
+ const { theme } = config;
13
+ const [primary, secondary] = config.services;
14
+
15
+ type ConnectionStatus = "connecting" | "connected" | "disconnected";
16
+
17
+ // ── Service Logos (DOM version for live demo) ────────────────────────────────
18
+
19
+ const PrimaryLogo: React.FC<{ size?: number; isActive?: boolean }> = ({
20
+ size = 70,
21
+ isActive,
22
+ }) => (
23
+ <div
24
+ style={{
25
+ borderRadius: 20,
26
+ boxShadow: isActive
27
+ ? `0 0 40px ${primary.color}`
28
+ : "0 10px 40px rgba(0,0,0,0.4)",
29
+ transition: "box-shadow 0.5s ease",
30
+ }}
31
+ >
32
+ {primary.logo === "svg" ? (
33
+ <svg width={size} height={size} viewBox="0 0 100 100" fill="none">
34
+ <rect width="100" height="100" rx="20" fill={primary.color} />
35
+ <path
36
+ d="M25 70 L35 45 L45 55 L55 30 L65 50 L75 25"
37
+ stroke="white"
38
+ strokeWidth="6"
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ fill="none"
42
+ />
43
+ <circle cx="75" cy="25" r="6" fill="white" />
44
+ </svg>
45
+ ) : (
46
+ <img
47
+ src={`/${primary.logo}`}
48
+ style={{ width: size, height: size, objectFit: "contain", borderRadius: 20 }}
49
+ />
50
+ )}
51
+ </div>
52
+ );
53
+
54
+ const SecondaryLogo: React.FC<{ size?: number; isActive?: boolean }> = ({
55
+ size = 70,
56
+ isActive,
57
+ }) => (
58
+ <div
59
+ style={{
60
+ borderRadius: 20,
61
+ boxShadow: isActive
62
+ ? `0 0 40px ${secondary.color}`
63
+ : "0 10px 40px rgba(0,0,0,0.4)",
64
+ transition: "box-shadow 0.5s ease",
65
+ }}
66
+ >
67
+ {secondary.logo === "svg" ? (
68
+ <svg width={size} height={size} viewBox="0 0 100 100" fill="none">
69
+ <rect width="100" height="100" rx="20" fill={secondary.color} />
70
+ <path
71
+ d="M25 70 L35 45 L45 55 L55 30 L65 50 L75 25"
72
+ stroke="white"
73
+ strokeWidth="6"
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ fill="none"
77
+ />
78
+ <circle cx="75" cy="25" r="6" fill="white" />
79
+ </svg>
80
+ ) : (
81
+ <img
82
+ src={`/${secondary.logo}`}
83
+ style={{ width: size, height: size, objectFit: "contain", borderRadius: 20 }}
84
+ />
85
+ )}
86
+ </div>
87
+ );
88
+
89
+ // ── Main Component ──────────────────────────────────────────────────────────
90
+
91
+ export const DemoApp: React.FC = () => {
92
+ const [status, setStatus] = useState<ConnectionStatus>("connecting");
93
+ const [eventHistory, setEventHistory] = useState<AnalyticsEvent[]>([]);
94
+ const [travelingEvents, setTravelingEvents] = useState<TravelingEvent[]>([]);
95
+ const [activeService, setActiveService] = useState({
96
+ primary: false,
97
+ secondary: false,
98
+ phone: false,
99
+ });
100
+ const [eventCounter, setEventCounter] = useState(0);
101
+ const [selectedEvent, setSelectedEvent] = useState<AnalyticsEvent | null>(null);
102
+
103
+ const primaryPathRef = useRef<SVGPathElement>(null);
104
+ const secondaryPathRef = useRef<SVGPathElement>(null);
105
+
106
+ // Animate traveling events at 60fps
107
+ useEffect(() => {
108
+ const interval = setInterval(() => {
109
+ setTravelingEvents((current) => {
110
+ const updated = current.map((e) => ({
111
+ ...e,
112
+ progress: Math.min(e.progress + 0.003, 1),
113
+ }));
114
+
115
+ updated.forEach((e) => {
116
+ if (e.progress >= 1) {
117
+ const key = e.target === primary.name ? "primary" : "secondary";
118
+ setActiveService((prev) => ({ ...prev, [key]: true }));
119
+ setTimeout(() => {
120
+ setActiveService((prev) => ({ ...prev, [key]: false }));
121
+ }, 500);
122
+ }
123
+ });
124
+
125
+ return updated.filter((e) => e.progress < 1);
126
+ });
127
+ }, 16);
128
+ return () => clearInterval(interval);
129
+ }, []);
130
+
131
+ // WebSocket connection
132
+ const connect = useCallback(() => {
133
+ setStatus("connecting");
134
+ const ws = new WebSocket(config.demo.wsUrl);
135
+
136
+ ws.onopen = () => setStatus("connected");
137
+
138
+ ws.onmessage = (messageEvent) => {
139
+ const data = JSON.parse(messageEvent.data);
140
+
141
+ if (data.type === "analytics_event") {
142
+ const analyticsEvent: AnalyticsEvent = {
143
+ event: data.event,
144
+ userId: data.userId,
145
+ deviceId: data.deviceId,
146
+ properties: data.properties || {},
147
+ timestamp: data.receivedAt,
148
+ };
149
+
150
+ setEventHistory((prev) => {
151
+ const isDuplicate = prev.some(
152
+ (e) =>
153
+ e.event === analyticsEvent.event &&
154
+ e.timestamp === analyticsEvent.timestamp
155
+ );
156
+ if (isDuplicate) return prev;
157
+ return [...prev, analyticsEvent];
158
+ });
159
+
160
+ // Create traveling events
161
+ setEventCounter((prev) => {
162
+ const newId = prev + 1;
163
+ const goesToSecondary = shouldGoToSecondary(analyticsEvent.event);
164
+
165
+ setTravelingEvents((cur) => {
166
+ const next = [...cur];
167
+ next.push({
168
+ ...analyticsEvent,
169
+ id: newId,
170
+ progress: 0,
171
+ target: primary.name,
172
+ direction: "toService",
173
+ });
174
+ if (goesToSecondary) {
175
+ next.push({
176
+ ...analyticsEvent,
177
+ id: newId + 0.5,
178
+ progress: 0,
179
+ target: secondary.name,
180
+ direction: "toService",
181
+ });
182
+ }
183
+ return next;
184
+ });
185
+
186
+ return newId;
187
+ });
188
+ }
189
+
190
+ if (data.type === "reset") {
191
+ setEventHistory([]);
192
+ setTravelingEvents([]);
193
+ }
194
+ };
195
+
196
+ ws.onclose = () => {
197
+ setStatus("disconnected");
198
+ setTimeout(connect, 2000);
199
+ };
200
+
201
+ ws.onerror = () => ws.close();
202
+ return ws;
203
+ }, []);
204
+
205
+ useEffect(() => {
206
+ const ws = connect();
207
+ return () => ws.close();
208
+ }, [connect]);
209
+
210
+ const handleBallClick = (_e: React.MouseEvent, event: TravelingEvent) => {
211
+ setSelectedEvent(event);
212
+ };
213
+
214
+ const closeCodePanel = () => setSelectedEvent(null);
215
+
216
+ // Escape to close
217
+ useEffect(() => {
218
+ if (!selectedEvent) return;
219
+ const handler = (e: KeyboardEvent) => {
220
+ if (e.key === "Escape") setSelectedEvent(null);
221
+ };
222
+ document.addEventListener("keydown", handler);
223
+ return () => document.removeEventListener("keydown", handler);
224
+ }, [selectedEvent]);
225
+
226
+ return (
227
+ <div style={styles.container}>
228
+ {/* Status bar */}
229
+ <div style={styles.statusBar}>
230
+ <div style={styles.statusIndicator}>
231
+ <div
232
+ style={{
233
+ ...styles.statusDot,
234
+ backgroundColor:
235
+ status === "connected"
236
+ ? "#4ade80"
237
+ : status === "connecting"
238
+ ? "#facc15"
239
+ : "#f87171",
240
+ }}
241
+ />
242
+ <span style={styles.statusText}>
243
+ {status === "connected"
244
+ ? "Live"
245
+ : status === "connecting"
246
+ ? "Connecting..."
247
+ : "Disconnected"}
248
+ </span>
249
+ </div>
250
+ <div style={styles.eventCount}>Events: {eventHistory.length}</div>
251
+ </div>
252
+
253
+ {/* Main visualization */}
254
+ <div style={styles.mainArea}>
255
+ <svg
256
+ style={styles.svg}
257
+ viewBox="0 0 1000 600"
258
+ preserveAspectRatio="xMidYMid meet"
259
+ >
260
+ {/* Primary path */}
261
+ <path
262
+ ref={primaryPathRef}
263
+ d="M 200 300 Q 450 200 750 180"
264
+ stroke={`${primary.color}4D`}
265
+ strokeWidth="3"
266
+ fill="none"
267
+ strokeDasharray="8 8"
268
+ style={{ pointerEvents: "none" }}
269
+ />
270
+ {/* Secondary path */}
271
+ <path
272
+ ref={secondaryPathRef}
273
+ d="M 200 300 Q 450 400 750 420"
274
+ stroke={`${secondary.color}4D`}
275
+ strokeWidth="3"
276
+ fill="none"
277
+ strokeDasharray="8 8"
278
+ style={{ pointerEvents: "none" }}
279
+ />
280
+
281
+ {/* Traveling balls */}
282
+ {travelingEvents.map((event) => {
283
+ const pathRef =
284
+ event.target === primary.name ? primaryPathRef : secondaryPathRef;
285
+ return (
286
+ <TravelingBall
287
+ key={`${event.id}-${event.target}`}
288
+ event={event}
289
+ pathRef={pathRef}
290
+ onClick={handleBallClick}
291
+ color={
292
+ event.target === primary.name
293
+ ? primary.color
294
+ : secondary.color
295
+ }
296
+ />
297
+ );
298
+ })}
299
+ </svg>
300
+
301
+ {/* iPhone */}
302
+ <div style={styles.iphoneContainer}>
303
+ <div
304
+ style={{
305
+ borderRadius: 36,
306
+ boxShadow: activeService.phone
307
+ ? "0 0 40px rgba(76, 175, 80, 0.8)"
308
+ : "none",
309
+ transition: "box-shadow 0.3s ease",
310
+ }}
311
+ >
312
+ <MiniIPhoneFrame />
313
+ </div>
314
+ <div style={styles.iphoneLabel}>{config.appName} App</div>
315
+ </div>
316
+
317
+ {/* Primary service */}
318
+ <div style={styles.primaryContainer}>
319
+ <PrimaryLogo size={70} isActive={activeService.primary} />
320
+ <div style={styles.serviceLabel}>{primary.name}</div>
321
+ </div>
322
+
323
+ {/* Secondary service */}
324
+ <div style={styles.secondaryContainer}>
325
+ <SecondaryLogo size={70} isActive={activeService.secondary} />
326
+ <div style={styles.serviceLabel}>{secondary.name}</div>
327
+ </div>
328
+
329
+ {travelingEvents.length > 0 && (
330
+ <div style={styles.hintText}>Click on a ball to see the code</div>
331
+ )}
332
+ </div>
333
+
334
+ {/* Sidebar */}
335
+ <div style={styles.sidebar}>
336
+ <h3 style={styles.sidebarTitle}>Event History</h3>
337
+ <div style={styles.eventList}>
338
+ {[...eventHistory].reverse().map((evt, i) => {
339
+ const goesToSec = shouldGoToSecondary(evt.event);
340
+ const eventColor = goesToSec ? secondary.color : primary.color;
341
+
342
+ return (
343
+ <div
344
+ key={eventHistory.length - 1 - i}
345
+ style={{
346
+ ...styles.eventItem,
347
+ opacity: i === 0 ? 1 : 0.7,
348
+ borderLeftWidth: 3,
349
+ borderLeftColor: eventColor,
350
+ borderLeftStyle: "solid",
351
+ }}
352
+ onClick={() => setSelectedEvent(evt)}
353
+ >
354
+ <div style={{ ...styles.eventName, color: eventColor }}>
355
+ {evt.event}
356
+ </div>
357
+ <div style={styles.eventMeta}>
358
+ {evt.userId
359
+ ? `User: ${evt.userId.slice(0, 8)}...`
360
+ : "Anonymous"}
361
+ </div>
362
+ </div>
363
+ );
364
+ })}
365
+ {eventHistory.length === 0 && (
366
+ <div style={styles.noEvents}>Waiting for events...</div>
367
+ )}
368
+ </div>
369
+ </div>
370
+
371
+ {/* Code Panel Overlay */}
372
+ {selectedEvent && (
373
+ <>
374
+ <div style={styles.overlay} onClick={closeCodePanel} />
375
+ <LiveCodePanel event={selectedEvent} onClose={closeCodePanel} />
376
+ </>
377
+ )}
378
+
379
+ <style>{`
380
+ @keyframes fadeIn {
381
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
382
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
383
+ }
384
+ @keyframes pulse {
385
+ 0%, 100% { opacity: 0.2; transform: scale(1); }
386
+ 50% { opacity: 0.4; transform: scale(1.15); }
387
+ }
388
+ @keyframes ballGlow {
389
+ 0%, 100% { opacity: 1; }
390
+ 50% { opacity: 0.8; }
391
+ }
392
+ `}</style>
393
+ </div>
394
+ );
395
+ };
396
+
397
+ // ── Styles ──────────────────────────────────────────────────────────────────
398
+
399
+ const styles: { [key: string]: React.CSSProperties } = {
400
+ container: {
401
+ width: "100vw",
402
+ height: "100vh",
403
+ display: "flex",
404
+ flexDirection: "column",
405
+ background:
406
+ "linear-gradient(135deg, #0a0a12 0%, #12121f 50%, #0a0a12 100%)",
407
+ overflow: "hidden",
408
+ },
409
+ statusBar: {
410
+ display: "flex",
411
+ justifyContent: "space-between",
412
+ alignItems: "center",
413
+ padding: "12px 24px",
414
+ borderBottom: "1px solid rgba(255,255,255,0.1)",
415
+ zIndex: 10,
416
+ },
417
+ statusIndicator: { display: "flex", alignItems: "center", gap: 8 },
418
+ statusDot: { width: 10, height: 10, borderRadius: "50%" },
419
+ statusText: { color: "rgba(255,255,255,0.8)", fontSize: 14, fontWeight: 500 },
420
+ eventCount: { color: "rgba(255,255,255,0.6)", fontSize: 14 },
421
+ mainArea: { flex: 1, position: "relative", marginRight: 260 },
422
+ svg: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%" },
423
+ iphoneContainer: {
424
+ position: "absolute",
425
+ left: 80,
426
+ top: "50%",
427
+ transform: "translateY(-50%)",
428
+ display: "flex",
429
+ flexDirection: "column",
430
+ alignItems: "center",
431
+ gap: 16,
432
+ },
433
+ iphoneLabel: { color: "rgba(255,255,255,0.6)", fontSize: 14, fontWeight: 500 },
434
+ primaryContainer: {
435
+ position: "absolute",
436
+ right: 80,
437
+ top: "30%",
438
+ transform: "translateY(-50%)",
439
+ display: "flex",
440
+ flexDirection: "column",
441
+ alignItems: "center",
442
+ gap: 12,
443
+ },
444
+ secondaryContainer: {
445
+ position: "absolute",
446
+ right: 80,
447
+ top: "70%",
448
+ transform: "translateY(-50%)",
449
+ display: "flex",
450
+ flexDirection: "column",
451
+ alignItems: "center",
452
+ gap: 12,
453
+ },
454
+ serviceLabel: { color: "white", fontSize: 16, fontWeight: 600 },
455
+ hintText: {
456
+ position: "absolute",
457
+ bottom: 30,
458
+ left: "50%",
459
+ transform: "translateX(-50%)",
460
+ color: "rgba(255,255,255,0.4)",
461
+ fontSize: 13,
462
+ fontStyle: "italic",
463
+ },
464
+ sidebar: {
465
+ position: "fixed",
466
+ right: 0,
467
+ top: 0,
468
+ bottom: 0,
469
+ width: 260,
470
+ background: "rgba(0,0,0,0.4)",
471
+ borderLeft: "1px solid rgba(255,255,255,0.1)",
472
+ padding: 20,
473
+ display: "flex",
474
+ flexDirection: "column",
475
+ },
476
+ sidebarTitle: {
477
+ color: "white",
478
+ fontSize: 16,
479
+ fontWeight: 600,
480
+ marginBottom: 16,
481
+ paddingBottom: 12,
482
+ borderBottom: "1px solid rgba(255,255,255,0.1)",
483
+ },
484
+ eventList: {
485
+ flex: 1,
486
+ overflowY: "auto",
487
+ display: "flex",
488
+ flexDirection: "column",
489
+ gap: 8,
490
+ },
491
+ eventItem: {
492
+ background: "rgba(255,255,255,0.05)",
493
+ borderRadius: 8,
494
+ padding: 12,
495
+ border: "1px solid rgba(255,255,255,0.1)",
496
+ cursor: "pointer",
497
+ transition: "all 0.3s ease",
498
+ },
499
+ eventName: {
500
+ fontSize: 13,
501
+ fontWeight: 600,
502
+ fontFamily: "SF Mono, monospace",
503
+ },
504
+ eventMeta: {
505
+ color: "rgba(255,255,255,0.5)",
506
+ fontSize: 11,
507
+ marginTop: 4,
508
+ },
509
+ noEvents: {
510
+ color: "rgba(255,255,255,0.3)",
511
+ fontSize: 13,
512
+ textAlign: "center",
513
+ padding: 20,
514
+ },
515
+ overlay: {
516
+ position: "fixed",
517
+ top: 0,
518
+ left: 0,
519
+ right: 0,
520
+ bottom: 0,
521
+ background: "rgba(0,0,0,0.5)",
522
+ zIndex: 999,
523
+ },
524
+ };
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { DemoApp } from './DemoApp';
4
+
5
+ const container = document.getElementById('root');
6
+ const root = createRoot(container!);
7
+
8
+ root.render(
9
+ <React.StrictMode>
10
+ <DemoApp />
11
+ </React.StrictMode>
12
+ );
@@ -0,0 +1,137 @@
1
+ // ============================================================================
2
+ // demo.config.ts — Edit this file to customize your analytics demo
3
+ // ============================================================================
4
+
5
+ export const config = {
6
+ // ── App Identity ──────────────────────────────────────────────────────────
7
+ appName: "MyApp",
8
+
9
+ // ── Theme ─────────────────────────────────────────────────────────────────
10
+ theme: {
11
+ bg: "#0F1119",
12
+ bgGradient: "#1E1E29",
13
+ accent: "#FC5B40",
14
+ accentSoft: "#FC7A57",
15
+ textPrimary: "#FFFFFF",
16
+ textSecondary: "rgba(255,255,255,0.7)",
17
+ surface: "rgba(255,255,255,0.08)",
18
+ stroke: "rgba(255,255,255,0.25)",
19
+ },
20
+
21
+ // ── Analytics Services ────────────────────────────────────────────────────
22
+ // The two services shown in the visualization (left = primary, right = secondary)
23
+ services: [
24
+ {
25
+ name: "Amplitude",
26
+ color: "#1E61F0",
27
+ // "svg" uses the built-in Amplitude logo; or set an image filename from public/
28
+ logo: "svg" as "svg" | string,
29
+ },
30
+ {
31
+ name: "Braze",
32
+ color: "#A633D6",
33
+ // Place your logo in public/ and reference it here
34
+ logo: "braze.png",
35
+ },
36
+ ],
37
+
38
+ // ── Event Categories ──────────────────────────────────────────────────────
39
+ // Maps event names (lowercase) → display category
40
+ eventCategories: {
41
+ "login completed": "Authentication",
42
+ "signup completed": "Authentication",
43
+ "logout completed": "Authentication",
44
+ "screen viewed": "Navigation",
45
+ "home viewed": "Navigation",
46
+ "store viewed": "Stores",
47
+ "product viewed": "Products",
48
+ "product added to cart": "Products",
49
+ "cart viewed": "Cart",
50
+ "checkout started": "Checkout",
51
+ "purchase completed": "Checkout",
52
+ "search performed": "Search",
53
+ "campaign opened": "Attribution",
54
+ "application opened": "App Lifecycle",
55
+ } as Record<string, string>,
56
+
57
+ // ── Secondary Service Events ──────────────────────────────────────────────
58
+ // Events that ALSO go to the secondary service (e.g. Braze)
59
+ secondaryServiceEvents: [
60
+ "purchase_completed",
61
+ "Purchase Completed",
62
+ "campaign_opened",
63
+ "Campaign Opened",
64
+ ],
65
+
66
+ // ── Code Snippets ─────────────────────────────────────────────────────────
67
+ // Shown in the code panel when clicking an event ball / sidebar item
68
+ codeSnippets: {
69
+ "login completed": {
70
+ file: "AuthManager.swift",
71
+ code: [
72
+ "func handleLoginSuccess(user: User) {",
73
+ " analytics.setUserId(user.uid)",
74
+ " engagement.changeUser(externalId: user.uid)",
75
+ " ",
76
+ ' analytics.track("Login Completed",',
77
+ ' properties: ["method": "email"]',
78
+ " )",
79
+ "}",
80
+ ],
81
+ },
82
+ "purchase completed": {
83
+ file: "CheckoutViewModel.swift",
84
+ code: [
85
+ "func completePurchase(order: Order) {",
86
+ ' analytics.track("Purchase Completed",',
87
+ " properties: [",
88
+ ' "order_id": order.id,',
89
+ ' "total": order.total',
90
+ " ]",
91
+ " )",
92
+ " engagement.logPurchase(order)",
93
+ "}",
94
+ ],
95
+ },
96
+ "store viewed": {
97
+ file: "StoreDetailViewModel.swift",
98
+ code: [
99
+ "func trackStoreView() {",
100
+ ' analytics.track("Store Viewed",',
101
+ " properties: [",
102
+ ' "store_id": store.id,',
103
+ ' "store_name": store.name',
104
+ " ]",
105
+ " )",
106
+ "}",
107
+ ],
108
+ },
109
+ } as Record<string, { file: string; code: string[] }>,
110
+
111
+ // ── App Categories (shown in the phone UI) ────────────────────────────────
112
+ categories: [
113
+ { name: "Restaurants", emoji: "\u{1F37D}\u{FE0F}" },
114
+ { name: "Groceries", emoji: "\u{1F6D2}" },
115
+ { name: "Pharmacy", emoji: "\u{1F48A}" },
116
+ { name: "Drinks", emoji: "\u{2615}" },
117
+ ],
118
+
119
+ // ── Video Settings (Remotion) ─────────────────────────────────────────────
120
+ video: {
121
+ width: 1280,
122
+ height: 720,
123
+ fps: 30,
124
+ durationInFrames: 600,
125
+ },
126
+
127
+ // ── Server ────────────────────────────────────────────────────────────────
128
+ server: {
129
+ port: 3001,
130
+ },
131
+
132
+ // ── Demo (browser visualization) ──────────────────────────────────────────
133
+ demo: {
134
+ port: 3000,
135
+ wsUrl: "ws://localhost:3001",
136
+ },
137
+ };