onboardme-sdk 0.0.1

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 (69) hide show
  1. package/ARCHITECTURE-v2.md +225 -0
  2. package/dist/sdk.iife.js +348 -0
  3. package/package.json +22 -0
  4. package/src/__tests__/day1.test.ts +37 -0
  5. package/src/__tests__/day2.test.ts +447 -0
  6. package/src/__tests__/day3.test.ts +110 -0
  7. package/src/__tests__/day4.test.ts +115 -0
  8. package/src/__tests__/day5.test.ts +102 -0
  9. package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
  10. package/src/__tests__/snapshot-sender.test.ts +111 -0
  11. package/src/__tests__/v2-integration.test.ts +305 -0
  12. package/src/__tests__/v2-positioner.test.ts +115 -0
  13. package/src/__tests__/v2-renderer.test.ts +189 -0
  14. package/src/__tests__/v2-types.test.ts +74 -0
  15. package/src/__tests__/week2-day1.test.ts +62 -0
  16. package/src/__tests__/week2-day2.test.ts +128 -0
  17. package/src/__tests__/week2-day3.test.ts +128 -0
  18. package/src/__tests__/week2-day4.test.ts +177 -0
  19. package/src/__tests__/week2-day5.test.ts +294 -0
  20. package/src/__tests__/week3-day1.test.ts +169 -0
  21. package/src/__tests__/week3-day2.test.ts +267 -0
  22. package/src/__tests__/week3-day3.test.ts +213 -0
  23. package/src/__tests__/week3-day4.test.ts +213 -0
  24. package/src/__tests__/week3-day5.test.ts +350 -0
  25. package/src/__tests__/week4-day1.test.ts +277 -0
  26. package/src/__tests__/week4-day2.test.ts +227 -0
  27. package/src/__tests__/week4-day3.test.ts +323 -0
  28. package/src/__tests__/week4-day4.test.ts +210 -0
  29. package/src/__tests__/week4-day5.test.ts +503 -0
  30. package/src/__tests__/week5-day1.test.ts +152 -0
  31. package/src/__tests__/week5-day2.test.ts +222 -0
  32. package/src/__tests__/week5-day3.test.ts +297 -0
  33. package/src/__tests__/week5-day4.test.ts +306 -0
  34. package/src/__tests__/week5-day5.test.ts +345 -0
  35. package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
  36. package/src/auto-generate/context-collector.ts +47 -0
  37. package/src/auto-generate/flow-generator-client.ts +97 -0
  38. package/src/browser.ts +5 -0
  39. package/src/components/celebration.ts +44 -0
  40. package/src/components/checklist-css.ts +159 -0
  41. package/src/components/checklist.ts +295 -0
  42. package/src/components/modal-css.ts +96 -0
  43. package/src/components/modal.ts +171 -0
  44. package/src/components/shadow-host.ts +30 -0
  45. package/src/core/api-client.ts +39 -0
  46. package/src/core/api-flows.ts +204 -0
  47. package/src/core/config.ts +37 -0
  48. package/src/core/event-batcher.ts +169 -0
  49. package/src/core/sdk.ts +301 -0
  50. package/src/detection/user-detection.ts +55 -0
  51. package/src/index.ts +95 -0
  52. package/src/snapshot/dom-collector.ts +193 -0
  53. package/src/snapshot/sender.ts +105 -0
  54. package/src/storage/event-listener.ts +59 -0
  55. package/src/storage/progress-tracker.ts +78 -0
  56. package/src/styles/checklist-css.ts +159 -0
  57. package/src/styles/checklist.css +166 -0
  58. package/src/styles/modal-css.ts +96 -0
  59. package/src/styles/modal.css +102 -0
  60. package/src/utils/dom.ts +49 -0
  61. package/src/utils/fingerprint.ts +20 -0
  62. package/src/utils/logger.ts +17 -0
  63. package/src/v2/positioner.ts +105 -0
  64. package/src/v2/renderer.ts +287 -0
  65. package/src/v2/styles.ts +89 -0
  66. package/src/v2/types.ts +53 -0
  67. package/tsconfig.json +11 -0
  68. package/vite.config.ts +28 -0
  69. package/vitest.config.ts +7 -0
@@ -0,0 +1,204 @@
1
+ import type { FlowConfig } from '@onboardme/types'
2
+ import { logger } from '../utils/logger.js'
3
+
4
+ export interface FlowsResponse {
5
+ flows: FlowConfig[]
6
+ checksumHash: string
7
+ }
8
+
9
+ interface CachedFlows {
10
+ flows: FlowConfig[]
11
+ checksumHash: string
12
+ timestamp: number
13
+ }
14
+
15
+ // In-memory cache for flows and their checksum
16
+ let _cachedFlows: CachedFlows | null = null
17
+ let _pollInterval: ReturnType<typeof setInterval> | null = null
18
+ let _lastChecksumHash: string = ''
19
+
20
+ const POLL_INTERVAL_MS = 60000 // 60 seconds
21
+ const CACHE_TIMEOUT_MS = 3600000 // 1 hour
22
+
23
+ /**
24
+ * Fetches flows from the API endpoint with a 10s timeout.
25
+ * Returns null if fetch fails (network error, timeout, parse error).
26
+ * Never throws.
27
+ */
28
+ export async function fetchFlowsFromAPI(endpoint: string, apiKey: string): Promise<FlowsResponse | null> {
29
+ try {
30
+ const controller = new AbortController()
31
+ const timeoutId = setTimeout(() => controller.abort(), 10000)
32
+
33
+ const response = await fetch(`${endpoint}/v1/flows`, {
34
+ method: 'GET',
35
+ headers: {
36
+ 'x-api-key': apiKey,
37
+ 'Content-Type': 'application/json',
38
+ },
39
+ signal: controller.signal,
40
+ })
41
+
42
+ clearTimeout(timeoutId)
43
+
44
+ if (!response.ok) {
45
+ logger.warn(`Failed to fetch flows: HTTP ${response.status}`)
46
+ return null
47
+ }
48
+
49
+ const data = await response.json() as FlowsResponse
50
+ return data
51
+ } catch (error) {
52
+ if (error instanceof Error) {
53
+ if (error.name === 'AbortError') {
54
+ logger.warn('Fetch flows timeout (10s)')
55
+ } else {
56
+ logger.warn(`Fetch flows error: ${error.message}`)
57
+ }
58
+ }
59
+ return null
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Loads flows from localStorage cache.
65
+ * Returns null if cache is missing or expired.
66
+ */
67
+ export function loadFlowsFromCache(cacheKey: string): CachedFlows | null {
68
+ try {
69
+ const cached = localStorage.getItem(cacheKey)
70
+ if (!cached) return null
71
+
72
+ const data = JSON.parse(cached) as CachedFlows
73
+ const age = Date.now() - data.timestamp
74
+ if (age > CACHE_TIMEOUT_MS) {
75
+ logger.log('Flows cache expired (1h)')
76
+ localStorage.removeItem(cacheKey)
77
+ return null
78
+ }
79
+
80
+ return data
81
+ } catch (error) {
82
+ logger.warn('Failed to parse flows cache')
83
+ return null
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Saves flows to localStorage cache with timestamp.
89
+ */
90
+ export function saveFlowsToCache(cacheKey: string, flows: FlowConfig[], checksumHash: string): void {
91
+ try {
92
+ const data: CachedFlows = {
93
+ flows,
94
+ checksumHash,
95
+ timestamp: Date.now(),
96
+ }
97
+ localStorage.setItem(cacheKey, JSON.stringify(data))
98
+ } catch (error) {
99
+ logger.warn('Failed to save flows to cache')
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Fetches flows from API or returns cached flows.
105
+ * On success, caches the flows and returns them.
106
+ * On API failure, returns cached flows if available.
107
+ * On all failure, returns empty array (silent fallback).
108
+ */
109
+ export async function fetchFlowsWithFallback(
110
+ endpoint: string,
111
+ apiKey: string,
112
+ cacheKey: string,
113
+ ): Promise<{ flows: FlowConfig[]; checksumHash: string }> {
114
+ // Try API first
115
+ const apiResponse = await fetchFlowsFromAPI(endpoint, apiKey)
116
+ if (apiResponse) {
117
+ saveFlowsToCache(cacheKey, apiResponse.flows, apiResponse.checksumHash)
118
+ _cachedFlows = {
119
+ flows: apiResponse.flows,
120
+ checksumHash: apiResponse.checksumHash,
121
+ timestamp: Date.now(),
122
+ }
123
+ return apiResponse
124
+ }
125
+
126
+ // Fall back to cache
127
+ const cached = loadFlowsFromCache(cacheKey)
128
+ if (cached) {
129
+ logger.log('Using cached flows from localStorage')
130
+ _cachedFlows = cached
131
+ return {
132
+ flows: cached.flows,
133
+ checksumHash: cached.checksumHash,
134
+ }
135
+ }
136
+
137
+ // No cache and API failed — return empty
138
+ logger.warn('No flows available (API down, cache empty)')
139
+ return { flows: [], checksumHash: '' }
140
+ }
141
+
142
+ /**
143
+ * Starts polling the API for flow updates every 60s.
144
+ * Only re-renders if checksumHash changes (intelligent caching).
145
+ * Polling stops and restarts on demand — see startFlowsPolling().
146
+ */
147
+ export function startFlowsPolling(
148
+ endpoint: string,
149
+ apiKey: string,
150
+ cacheKey: string,
151
+ onFlowsUpdated: (flows: FlowConfig[], checksumHash: string) => void,
152
+ ): void {
153
+ if (_pollInterval) return; // Already polling
154
+
155
+ logger.log('Starting flows polling (60s interval)')
156
+
157
+ // First check immediately
158
+ void (async () => {
159
+ const response = await fetchFlowsFromAPI(endpoint, apiKey)
160
+ if (response && response.checksumHash !== _lastChecksumHash) {
161
+ _lastChecksumHash = response.checksumHash
162
+ saveFlowsToCache(cacheKey, response.flows, response.checksumHash)
163
+ onFlowsUpdated(response.flows, response.checksumHash)
164
+ }
165
+ })()
166
+
167
+ // Then poll every 60s
168
+ _pollInterval = setInterval(async () => {
169
+ const response = await fetchFlowsFromAPI(endpoint, apiKey)
170
+ if (response && response.checksumHash !== _lastChecksumHash) {
171
+ logger.log('Flows updated (new checksum detected)')
172
+ _lastChecksumHash = response.checksumHash
173
+ saveFlowsToCache(cacheKey, response.flows, response.checksumHash)
174
+ onFlowsUpdated(response.flows, response.checksumHash)
175
+ }
176
+ }, POLL_INTERVAL_MS)
177
+ }
178
+
179
+ /**
180
+ * Stops the flows polling interval.
181
+ */
182
+ export function stopFlowsPolling(): void {
183
+ if (_pollInterval) {
184
+ clearInterval(_pollInterval)
185
+ _pollInterval = null
186
+ logger.log('Flows polling stopped')
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Gets the currently cached flows. Used by tests or debugging.
192
+ */
193
+ export function getCachedFlows(): CachedFlows | null {
194
+ return _cachedFlows
195
+ }
196
+
197
+ /**
198
+ * Clears all cached flows and stops polling. Used in tests.
199
+ */
200
+ export function _clearFlowsCache(): void {
201
+ _cachedFlows = null
202
+ _lastChecksumHash = ''
203
+ stopFlowsPolling()
204
+ }
@@ -0,0 +1,37 @@
1
+ import type { OnboardMeConfig } from '@onboardme/types';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ /**
5
+ * Validates and normalises the raw config passed to OnboardMe.init().
6
+ * All failures are logger.warn — never throw.
7
+ * Returns a normalised config with defaults applied.
8
+ */
9
+ export function validateConfig(raw: unknown): OnboardMeConfig | null {
10
+ if (!raw || typeof raw !== 'object') {
11
+ logger.warn('init() called with no config — OnboardMe will not run.');
12
+ return null;
13
+ }
14
+
15
+ const config = raw as Record<string, unknown>;
16
+
17
+ if (!config['productId'] || typeof config['productId'] !== 'string') {
18
+ logger.warn('config.productId is required and must be a string — OnboardMe will not run.');
19
+ return null;
20
+ }
21
+
22
+ if (config['flows'] !== undefined && !Array.isArray(config['flows'])) {
23
+ logger.warn('config.flows must be an array — defaulting to [].');
24
+ config['flows'] = [];
25
+ }
26
+
27
+ // Apply defaults
28
+ return {
29
+ productId: config['productId'] as string,
30
+ eventsEndpoint: config['eventsEndpoint'] as string | undefined,
31
+ autoGenerate: config['autoGenerate'] as OnboardMeConfig['autoGenerate'] ?? undefined,
32
+ apiFlows: config['apiFlows'] as OnboardMeConfig['apiFlows'] ?? undefined,
33
+ flows: (config['flows'] as OnboardMeConfig['flows']) ?? [],
34
+ user: config['user'] as OnboardMeConfig['user'] ?? undefined,
35
+ debug: typeof config['debug'] === 'boolean' ? config['debug'] : false,
36
+ };
37
+ }
@@ -0,0 +1,169 @@
1
+ import type { OnboardingEvent, EventType } from '@onboardme/types';
2
+ import { logger } from '../utils/logger.js';
3
+ import { postEvents } from './api-client.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Module state
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const MAX_QUEUE_SIZE = 100;
10
+ const AUTO_FLUSH_DELAY_MS = 5_000;
11
+
12
+ let _endpoint = '';
13
+ let _anonymousId = '';
14
+ let _userId: string | undefined;
15
+ let _queue: OnboardingEvent[] = [];
16
+ let _flushTimer: ReturnType<typeof setTimeout> | null = null;
17
+ let _listenersAttached = false;
18
+ let _visibilityHandler: (() => void) | null = null;
19
+ let _beforeUnloadHandler: (() => void) | null = null;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Public API
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Configures the batcher with the endpoint and anonymousId to use for all events.
27
+ * Must be called once during init() before any pushEvent calls.
28
+ */
29
+ export function configureBatcher(endpoint: string, anonymousId: string): void {
30
+ _endpoint = endpoint;
31
+ _anonymousId = anonymousId;
32
+ }
33
+
34
+ /** Sets the authenticated userId to include on future events after identify(). */
35
+ export function setBatcherUserId(userId: string): void {
36
+ _userId = userId;
37
+ }
38
+
39
+ /**
40
+ * Adds an event to the in-memory queue.
41
+ * Auto-fills: eventId, anonymousId, userId, pageUrl, timestamp.
42
+ * Starts the 5s auto-flush timer on the first event.
43
+ * Drops the oldest event if the queue exceeds MAX_QUEUE_SIZE.
44
+ */
45
+ export function pushEvent(
46
+ eventType: EventType,
47
+ partial: Omit<OnboardingEvent, 'eventId' | 'anonymousId' | 'eventType' | 'pageUrl' | 'timestamp'> = {},
48
+ ): void {
49
+ const event: OnboardingEvent = {
50
+ eventId: crypto.randomUUID(),
51
+ anonymousId: _anonymousId,
52
+ ...((_userId !== undefined) ? { userId: _userId } : {}),
53
+ eventType,
54
+ pageUrl: typeof window !== 'undefined' ? window.location.href : '',
55
+ timestamp: Date.now(),
56
+ ...partial,
57
+ };
58
+
59
+ if (_queue.length >= MAX_QUEUE_SIZE) {
60
+ _queue.shift(); // drop oldest to prevent unbounded growth
61
+ logger.warn('event queue full — oldest event dropped');
62
+ }
63
+
64
+ _queue.push(event);
65
+ logger.log(`event queued: ${eventType} (queue size: ${_queue.length})`);
66
+
67
+ // Start auto-flush timer only on the first event
68
+ if (_queue.length === 1 && _flushTimer === null) {
69
+ _flushTimer = setTimeout(() => {
70
+ _flushTimer = null;
71
+ flushEvents().catch(() => {});
72
+ }, AUTO_FLUSH_DELAY_MS);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Flushes all queued events to the configured endpoint.
78
+ * Clears the queue and cancels the timer on success.
79
+ * On failure: logs a warn, leaves the queue intact for retry.
80
+ * Never throws.
81
+ */
82
+ export async function flushEvents(): Promise<void> {
83
+ if (_queue.length === 0) return;
84
+ if (!_endpoint) {
85
+ logger.warn('flushEvents called before configureBatcher — events not sent');
86
+ return;
87
+ }
88
+
89
+ // Cancel any pending auto-flush timer
90
+ if (_flushTimer !== null) {
91
+ clearTimeout(_flushTimer);
92
+ _flushTimer = null;
93
+ }
94
+
95
+ const batch = [..._queue];
96
+ const ok = await postEvents(_endpoint, batch);
97
+
98
+ if (ok) {
99
+ _queue = [];
100
+ logger.log(`flushed ${batch.length} event(s)`);
101
+ }
102
+ // On failure: queue retained so next flush can retry
103
+ }
104
+
105
+ /**
106
+ * Attaches visibilitychange and beforeunload flush listeners.
107
+ * Safe to call multiple times — listeners are only registered once.
108
+ *
109
+ * - visibilitychange: flushes via fetch when the tab is hidden (user switches tabs).
110
+ * - beforeunload: flushes via sendBeacon — fire-and-forget POST that survives page unload.
111
+ * Regular fetch is unreliable during beforeunload; sendBeacon is the correct API for this.
112
+ */
113
+ export function attachFlushListeners(): void {
114
+ if (_listenersAttached) return;
115
+ _listenersAttached = true;
116
+
117
+ _visibilityHandler = () => {
118
+ if (document.visibilityState === 'hidden') {
119
+ flushEvents().catch(() => {});
120
+ }
121
+ };
122
+
123
+ _beforeUnloadHandler = () => {
124
+ if (_queue.length === 0 || !_endpoint) return;
125
+ const payload = JSON.stringify({ events: _queue });
126
+ const sent = navigator.sendBeacon(_endpoint, new Blob([payload], { type: 'application/json' }));
127
+ if (sent) {
128
+ _queue = [];
129
+ if (_flushTimer !== null) {
130
+ clearTimeout(_flushTimer);
131
+ _flushTimer = null;
132
+ }
133
+ }
134
+ };
135
+
136
+ document.addEventListener('visibilitychange', _visibilityHandler);
137
+ window.addEventListener('beforeunload', _beforeUnloadHandler);
138
+ }
139
+
140
+ /** Removes flush listeners — only used in tests to prevent jsdom listener accumulation. */
141
+ export function _detachFlushListeners(): void {
142
+ if (_visibilityHandler) {
143
+ document.removeEventListener('visibilitychange', _visibilityHandler);
144
+ _visibilityHandler = null;
145
+ }
146
+ if (_beforeUnloadHandler) {
147
+ window.removeEventListener('beforeunload', _beforeUnloadHandler);
148
+ _beforeUnloadHandler = null;
149
+ }
150
+ _listenersAttached = false;
151
+ }
152
+
153
+ /** Resets all batcher state — only used in tests. */
154
+ export function _resetBatcher(): void {
155
+ _detachFlushListeners();
156
+ if (_flushTimer !== null) {
157
+ clearTimeout(_flushTimer);
158
+ _flushTimer = null;
159
+ }
160
+ _endpoint = '';
161
+ _anonymousId = '';
162
+ _userId = undefined;
163
+ _queue = [];
164
+ }
165
+
166
+ /** Returns a copy of the current queue — only used in tests. */
167
+ export function _getQueue(): OnboardingEvent[] {
168
+ return [..._queue];
169
+ }