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.
- package/ARCHITECTURE-v2.md +225 -0
- package/dist/sdk.iife.js +348 -0
- package/package.json +22 -0
- package/src/__tests__/day1.test.ts +37 -0
- package/src/__tests__/day2.test.ts +447 -0
- package/src/__tests__/day3.test.ts +110 -0
- package/src/__tests__/day4.test.ts +115 -0
- package/src/__tests__/day5.test.ts +102 -0
- package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
- package/src/__tests__/snapshot-sender.test.ts +111 -0
- package/src/__tests__/v2-integration.test.ts +305 -0
- package/src/__tests__/v2-positioner.test.ts +115 -0
- package/src/__tests__/v2-renderer.test.ts +189 -0
- package/src/__tests__/v2-types.test.ts +74 -0
- package/src/__tests__/week2-day1.test.ts +62 -0
- package/src/__tests__/week2-day2.test.ts +128 -0
- package/src/__tests__/week2-day3.test.ts +128 -0
- package/src/__tests__/week2-day4.test.ts +177 -0
- package/src/__tests__/week2-day5.test.ts +294 -0
- package/src/__tests__/week3-day1.test.ts +169 -0
- package/src/__tests__/week3-day2.test.ts +267 -0
- package/src/__tests__/week3-day3.test.ts +213 -0
- package/src/__tests__/week3-day4.test.ts +213 -0
- package/src/__tests__/week3-day5.test.ts +350 -0
- package/src/__tests__/week4-day1.test.ts +277 -0
- package/src/__tests__/week4-day2.test.ts +227 -0
- package/src/__tests__/week4-day3.test.ts +323 -0
- package/src/__tests__/week4-day4.test.ts +210 -0
- package/src/__tests__/week4-day5.test.ts +503 -0
- package/src/__tests__/week5-day1.test.ts +152 -0
- package/src/__tests__/week5-day2.test.ts +222 -0
- package/src/__tests__/week5-day3.test.ts +297 -0
- package/src/__tests__/week5-day4.test.ts +306 -0
- package/src/__tests__/week5-day5.test.ts +345 -0
- package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
- package/src/auto-generate/context-collector.ts +47 -0
- package/src/auto-generate/flow-generator-client.ts +97 -0
- package/src/browser.ts +5 -0
- package/src/components/celebration.ts +44 -0
- package/src/components/checklist-css.ts +159 -0
- package/src/components/checklist.ts +295 -0
- package/src/components/modal-css.ts +96 -0
- package/src/components/modal.ts +171 -0
- package/src/components/shadow-host.ts +30 -0
- package/src/core/api-client.ts +39 -0
- package/src/core/api-flows.ts +204 -0
- package/src/core/config.ts +37 -0
- package/src/core/event-batcher.ts +169 -0
- package/src/core/sdk.ts +301 -0
- package/src/detection/user-detection.ts +55 -0
- package/src/index.ts +95 -0
- package/src/snapshot/dom-collector.ts +193 -0
- package/src/snapshot/sender.ts +105 -0
- package/src/storage/event-listener.ts +59 -0
- package/src/storage/progress-tracker.ts +78 -0
- package/src/styles/checklist-css.ts +159 -0
- package/src/styles/checklist.css +166 -0
- package/src/styles/modal-css.ts +96 -0
- package/src/styles/modal.css +102 -0
- package/src/utils/dom.ts +49 -0
- package/src/utils/fingerprint.ts +20 -0
- package/src/utils/logger.ts +17 -0
- package/src/v2/positioner.ts +105 -0
- package/src/v2/renderer.ts +287 -0
- package/src/v2/styles.ts +89 -0
- package/src/v2/types.ts +53 -0
- package/tsconfig.json +11 -0
- package/vite.config.ts +28 -0
- package/vitest.config.ts +7 -0
package/src/core/sdk.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { OnboardMeConfig, FlowConfig, FlowStep } from '@onboardme/types';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
import { validateConfig } from './config.js';
|
|
4
|
+
import { detectUser } from '../detection/user-detection.js';
|
|
5
|
+
import { getAnonymousId } from '../utils/fingerprint.js';
|
|
6
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
7
|
+
import { showModal } from '../components/modal.js';
|
|
8
|
+
import { renderChecklist } from '../components/checklist.js';
|
|
9
|
+
import { showCelebration } from '../components/celebration.js';
|
|
10
|
+
import { loadProgress } from '../storage/progress-tracker.js';
|
|
11
|
+
import { configureBatcher, attachFlushListeners, pushEvent } from './event-batcher.js';
|
|
12
|
+
import { collectContext } from '../auto-generate/context-collector.js';
|
|
13
|
+
import { fetchGeneratedFlows, mergeFlows } from '../auto-generate/flow-generator-client.js';
|
|
14
|
+
import { fetchFlowsWithFallback, startFlowsPolling, stopFlowsPolling } from './api-flows.js';
|
|
15
|
+
import { collectAndSendSnapshot } from '../snapshot/sender.js';
|
|
16
|
+
import { isV2FlowConfig } from '../v2/types.js';
|
|
17
|
+
import { renderV2Flow, type V2RenderHandle } from '../v2/renderer.js';
|
|
18
|
+
|
|
19
|
+
export interface SDKState {
|
|
20
|
+
config: OnboardMeConfig;
|
|
21
|
+
anonymousId: string;
|
|
22
|
+
showOnboarding: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Module-level initialised guard — prevents duplicate modals if the host app
|
|
26
|
+
// calls init() more than once (belt-and-suspenders alongside index.ts guard).
|
|
27
|
+
let _initialised = false;
|
|
28
|
+
let _config: OnboardMeConfig | null = null;
|
|
29
|
+
|
|
30
|
+
// Architecture v2 — Phase F. Active v2 render handles, keyed by flow id.
|
|
31
|
+
// Tracked at module scope so polling refreshes can tear down stale renders
|
|
32
|
+
// before mounting the new ones.
|
|
33
|
+
const _v2Handles = new Map<string, V2RenderHandle>();
|
|
34
|
+
|
|
35
|
+
function _teardownV2Renders(): void {
|
|
36
|
+
for (const handle of _v2Handles.values()) handle.destroy();
|
|
37
|
+
_v2Handles.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Core orchestrator — runs once on init().
|
|
42
|
+
* Returns the initialised SDK state, or null if config is invalid.
|
|
43
|
+
*
|
|
44
|
+
* Sequence:
|
|
45
|
+
* 1. Guard against double-init (warn, return null)
|
|
46
|
+
* 2. Validate config (warn, never throw)
|
|
47
|
+
* 3. Generate / retrieve anonymousId
|
|
48
|
+
* 4. Run user detection
|
|
49
|
+
* 5. Set showOnboarding flag
|
|
50
|
+
* 6. If apiFlows is configured: fetch flows from API + start polling
|
|
51
|
+
* 7. If new user: schedule showModal via setTimeout(fn, 0) — defers until
|
|
52
|
+
* after init() returns so initialisation stays synchronous
|
|
53
|
+
* 8. Warn if no flows are defined
|
|
54
|
+
*/
|
|
55
|
+
export function runSDK(raw: unknown): SDKState | null {
|
|
56
|
+
if (_initialised) {
|
|
57
|
+
logger.warn('OnboardMe already initialised — ignoring duplicate init() call.');
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const config = validateConfig(raw);
|
|
62
|
+
if (!config) return null;
|
|
63
|
+
|
|
64
|
+
_initialised = true;
|
|
65
|
+
_config = config;
|
|
66
|
+
|
|
67
|
+
logger.log(`initialised for product: ${config.productId}`);
|
|
68
|
+
|
|
69
|
+
const anonymousId = getAnonymousId(config.productId);
|
|
70
|
+
logger.log(`anonymous ID: ${anonymousId}`);
|
|
71
|
+
|
|
72
|
+
// Wire event batcher — must happen before any component emits events
|
|
73
|
+
configureBatcher(config.eventsEndpoint ?? 'http://localhost:3001/events', anonymousId);
|
|
74
|
+
attachFlushListeners();
|
|
75
|
+
|
|
76
|
+
const detection = detectUser(config);
|
|
77
|
+
const showOnboarding = detection.isNew;
|
|
78
|
+
|
|
79
|
+
if (detection.isNew) {
|
|
80
|
+
logger.log(`new user detected (reason: ${detection.reason})`);
|
|
81
|
+
} else {
|
|
82
|
+
logger.log(`returning user, skipping onboarding`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fetch flows from API if configured
|
|
86
|
+
if (config.apiFlows) {
|
|
87
|
+
void _bootstrapApiFlows(config, showOnboarding);
|
|
88
|
+
// Architecture v2 / Phase B — opportunistic DOM snapshot.
|
|
89
|
+
// Sends one snapshot per session per page so OnboardMe can build flows
|
|
90
|
+
// grounded in the host app's actual UI. Fire-and-forget; never blocks.
|
|
91
|
+
void _bootstrapSnapshot(config);
|
|
92
|
+
} else if (config.flows.length === 0) {
|
|
93
|
+
logger.warn('no flows defined in config — nothing to show');
|
|
94
|
+
} else {
|
|
95
|
+
_renderFlows(config.flows, config, showOnboarding);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (config.autoGenerate) {
|
|
99
|
+
void _bootstrapAutoGenerate(config, showOnboarding);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { config, anonymousId, showOnboarding };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sends a single DOM snapshot for the current page if apiFlows is configured.
|
|
107
|
+
* Debounced server-side AND client-side (sessionStorage). Never throws.
|
|
108
|
+
* Defers to setTimeout(0) so init() returns synchronously and the host
|
|
109
|
+
* page's first paint isn't delayed by snapshot collection.
|
|
110
|
+
*/
|
|
111
|
+
async function _bootstrapSnapshot(config: OnboardMeConfig): Promise<void> {
|
|
112
|
+
if (!config.apiFlows) return;
|
|
113
|
+
// Defer one tick so the SDK doesn't compete with first paint.
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
115
|
+
await collectAndSendSnapshot(
|
|
116
|
+
config.productId,
|
|
117
|
+
config.apiFlows.endpoint,
|
|
118
|
+
config.apiFlows.apiKey,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Renders the first modal step (for new users) and the first checklist flow
|
|
124
|
+
* from the given flows array. Shared by the sync init path and the async
|
|
125
|
+
* auto-generate path so both use identical rendering logic.
|
|
126
|
+
*/
|
|
127
|
+
export function _renderFlows(
|
|
128
|
+
flows: FlowConfig[],
|
|
129
|
+
config: OnboardMeConfig,
|
|
130
|
+
showOnboarding: boolean,
|
|
131
|
+
): void {
|
|
132
|
+
if (flows.length === 0) return;
|
|
133
|
+
|
|
134
|
+
// Architecture v2 — Phase F. Partition flows by schema. v2 flows go through
|
|
135
|
+
// the new renderer (tooltip / modal / highlight matching the v2 API schema);
|
|
136
|
+
// legacy flows continue down the original modal/checklist path below.
|
|
137
|
+
const legacyFlows: FlowConfig[] = [];
|
|
138
|
+
for (const flow of flows) {
|
|
139
|
+
if (isV2FlowConfig(flow.config ?? flow)) {
|
|
140
|
+
// Tear down any previous render of this same flow (e.g. on a polling
|
|
141
|
+
// refresh) before mounting the new version.
|
|
142
|
+
_v2Handles.get(flow.id)?.destroy();
|
|
143
|
+
_v2Handles.delete(flow.id);
|
|
144
|
+
|
|
145
|
+
// The v2 API stores steps under flow.config.steps; some legacy code
|
|
146
|
+
// paths nest them at flow.steps. Normalise here so the renderer
|
|
147
|
+
// accepts both shapes.
|
|
148
|
+
const v2Config = isV2FlowConfig(flow.config) ? flow.config : (flow as unknown as { steps: unknown }).steps
|
|
149
|
+
? { steps: (flow as unknown as { steps: unknown[] }).steps }
|
|
150
|
+
: null;
|
|
151
|
+
if (!v2Config) continue;
|
|
152
|
+
|
|
153
|
+
// Defer to a microtask so init() stays synchronous and host first paint
|
|
154
|
+
// isn't delayed by querying the DOM for tooltip targets.
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
const handle = renderV2Flow(v2Config as { steps: never[] }, getShadowRoot());
|
|
157
|
+
if (handle) _v2Handles.set(flow.id, handle);
|
|
158
|
+
}, 0);
|
|
159
|
+
} else {
|
|
160
|
+
legacyFlows.push(flow);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (legacyFlows.length === 0) return;
|
|
165
|
+
flows = legacyFlows;
|
|
166
|
+
|
|
167
|
+
if (showOnboarding) {
|
|
168
|
+
interface ModalCandidate { step: FlowStep; flowId: string }
|
|
169
|
+
const candidates: ModalCandidate[] = flows
|
|
170
|
+
.flatMap((f) => f.steps.filter((s) => s.type === 'modal').map((s) => ({ step: s, flowId: f.id })))
|
|
171
|
+
.sort((a, b) => a.step.order - b.step.order);
|
|
172
|
+
const first = candidates[0];
|
|
173
|
+
if (first) {
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
showModal(first.step, getShadowRoot(), config.productId, first.flowId);
|
|
176
|
+
}, 0);
|
|
177
|
+
} else {
|
|
178
|
+
logger.log('no modal step found — skipping welcome modal');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const flow of flows) {
|
|
183
|
+
if (!flow.steps.some((s) => s.type === 'checklist')) continue;
|
|
184
|
+
const progress = loadProgress(config.productId, flow.id);
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
renderChecklist(flow, progress, getShadowRoot(), config.debug ?? false, config.productId, flow.id);
|
|
187
|
+
}, 0);
|
|
188
|
+
break; // only the first checklist flow
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Async bootstrap for fetching flows from the OnboardMe API. Runs
|
|
194
|
+
* fire-and-forget after the synchronous init path. Only active when
|
|
195
|
+
* config.apiFlows is set. Fetches initial flows and starts polling
|
|
196
|
+
* for updates every 60 seconds.
|
|
197
|
+
*/
|
|
198
|
+
export async function _bootstrapApiFlows(
|
|
199
|
+
config: OnboardMeConfig,
|
|
200
|
+
showOnboarding: boolean,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
if (!config.apiFlows) return;
|
|
203
|
+
|
|
204
|
+
const cacheKey = `onboardme_flows_${config.productId}`;
|
|
205
|
+
const response = await fetchFlowsWithFallback(
|
|
206
|
+
config.apiFlows.endpoint,
|
|
207
|
+
config.apiFlows.apiKey,
|
|
208
|
+
cacheKey,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (response.flows.length === 0) {
|
|
212
|
+
logger.warn('no flows from API and no cache available');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Update module config with API flows
|
|
217
|
+
if (_config) {
|
|
218
|
+
_config = { ..._config, flows: response.flows };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_renderFlows(response.flows, config, showOnboarding);
|
|
222
|
+
|
|
223
|
+
// Start polling for updates — uses checksumHash to avoid unnecessary re-renders
|
|
224
|
+
startFlowsPolling(
|
|
225
|
+
config.apiFlows.endpoint,
|
|
226
|
+
config.apiFlows.apiKey,
|
|
227
|
+
cacheKey,
|
|
228
|
+
(flows: FlowConfig[]) => {
|
|
229
|
+
// Update config with new flows
|
|
230
|
+
if (_config) {
|
|
231
|
+
_config = { ..._config, flows };
|
|
232
|
+
}
|
|
233
|
+
// Re-render new flows
|
|
234
|
+
_renderFlows(flows, config, showOnboarding);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Async bootstrap for AI-generated flows. Runs fire-and-forget after the
|
|
241
|
+
* synchronous init path. Only active when config.autoGenerate is set.
|
|
242
|
+
* Collects DOM context, fetches generated flows, merges with manual flows,
|
|
243
|
+
* and renders any novel flows that are not already in the manual config.
|
|
244
|
+
*/
|
|
245
|
+
export async function _bootstrapAutoGenerate(
|
|
246
|
+
config: OnboardMeConfig,
|
|
247
|
+
showOnboarding: boolean,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
if (!config.autoGenerate) return;
|
|
250
|
+
|
|
251
|
+
const context = collectContext();
|
|
252
|
+
const generated = await fetchGeneratedFlows(
|
|
253
|
+
config.autoGenerate.endpoint,
|
|
254
|
+
config.productId,
|
|
255
|
+
context,
|
|
256
|
+
config.autoGenerate.cacheTtlMs,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (generated.length === 0) return;
|
|
260
|
+
|
|
261
|
+
const existingIds = new Set(config.flows.map((f) => f.id));
|
|
262
|
+
const novel = generated.filter((f) => !existingIds.has(f.id));
|
|
263
|
+
|
|
264
|
+
if (novel.length === 0) return;
|
|
265
|
+
|
|
266
|
+
// Update module config so checkCompletionGoal sees generated flows
|
|
267
|
+
if (_config) {
|
|
268
|
+
_config = { ..._config, flows: mergeFlows(_config.flows, generated) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_renderFlows(novel, config, showOnboarding);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Checks whether a tracked event name matches any active flow's completionGoal.
|
|
276
|
+
* If it does and the goal has not already fired, enqueues flow_completed +
|
|
277
|
+
* goal_reached events and shows the celebration banner.
|
|
278
|
+
* Called by index.ts track() on every OnboardMe.track() invocation.
|
|
279
|
+
*/
|
|
280
|
+
export function checkCompletionGoal(eventName: string): void {
|
|
281
|
+
if (!_config) return;
|
|
282
|
+
|
|
283
|
+
for (const flow of _config.flows) {
|
|
284
|
+
if (eventName !== flow.completionGoal) continue;
|
|
285
|
+
|
|
286
|
+
const key = `onboardme_flow_done_${_config.productId}_${flow.id}`;
|
|
287
|
+
if (localStorage.getItem(key)) continue; // already fired — prevent duplicate
|
|
288
|
+
|
|
289
|
+
localStorage.setItem(key, '1');
|
|
290
|
+
pushEvent('flow_completed', { flowId: flow.id });
|
|
291
|
+
pushEvent('goal_reached', { flowId: flow.id });
|
|
292
|
+
showCelebration(getShadowRoot());
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Resets the initialised flag — only used in tests. */
|
|
297
|
+
export function _resetSDK(): void {
|
|
298
|
+
_initialised = false;
|
|
299
|
+
_config = null;
|
|
300
|
+
stopFlowsPolling();
|
|
301
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { OnboardMeConfig } from '@onboardme/types';
|
|
2
|
+
|
|
3
|
+
export type DetectionResult =
|
|
4
|
+
| { isNew: true; reason: 'first_visit' | 'new_account' }
|
|
5
|
+
| { isNew: false; reason: 'returning' };
|
|
6
|
+
|
|
7
|
+
const NEW_ACCOUNT_THRESHOLD_MS = 48 * 60 * 60 * 1000; // 48 hours
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Three-layer first-time user detection, checked in order:
|
|
11
|
+
*
|
|
12
|
+
* Layer 1 — localStorage flag: if onboardme_seen_{productId} exists → returning user.
|
|
13
|
+
* Layer 2 — Account age: if config.user.createdAt < 48h ago → new user.
|
|
14
|
+
* Layer 3 — Fallback: no info → treat as new (better to show once too many than miss a new user).
|
|
15
|
+
*
|
|
16
|
+
* On a positive new-user result, the seen flag is written to localStorage
|
|
17
|
+
* so the next visit is correctly identified as returning.
|
|
18
|
+
*/
|
|
19
|
+
export function detectUser(config: OnboardMeConfig): DetectionResult {
|
|
20
|
+
const seenKey = `onboardme_seen_${config.productId}`;
|
|
21
|
+
|
|
22
|
+
// Layer 1 — localStorage flag
|
|
23
|
+
try {
|
|
24
|
+
if (localStorage.getItem(seenKey) !== null) {
|
|
25
|
+
return { isNew: false, reason: 'returning' };
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// localStorage unavailable — fall through to next layer
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Layer 2 — Account age
|
|
32
|
+
const createdAt = config.user?.createdAt;
|
|
33
|
+
if (createdAt) {
|
|
34
|
+
const ageMs = Date.now() - new Date(createdAt).getTime();
|
|
35
|
+
if (ageMs >= NEW_ACCOUNT_THRESHOLD_MS) {
|
|
36
|
+
// Old account, probably cleared their cache — treat as returning
|
|
37
|
+
return { isNew: false, reason: 'returning' };
|
|
38
|
+
}
|
|
39
|
+
// Account is fresh — new user
|
|
40
|
+
_markSeen(seenKey);
|
|
41
|
+
return { isNew: true, reason: 'new_account' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Layer 3 — Fallback: no information, assume new
|
|
45
|
+
_markSeen(seenKey);
|
|
46
|
+
return { isNew: true, reason: 'first_visit' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _markSeen(key: string): void {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(key, '1');
|
|
52
|
+
} catch {
|
|
53
|
+
// Silently ignore if localStorage is unavailable
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { OnboardMeConfig } from '@onboardme/types';
|
|
2
|
+
import { setDebug, logger } from './utils/logger.js';
|
|
3
|
+
import { runSDK, checkCompletionGoal } from './core/sdk.js';
|
|
4
|
+
import { setBatcherUserId, pushEvent } from './core/event-batcher.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Module state
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
let _initialised = false;
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Public API
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const OnboardMe = {
|
|
17
|
+
init(config: OnboardMeConfig): void {
|
|
18
|
+
if (_initialised) {
|
|
19
|
+
logger.warn('init() called more than once — ignoring duplicate call.');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Enable debug logging before any further output
|
|
24
|
+
if (config?.debug) setDebug(true);
|
|
25
|
+
|
|
26
|
+
const state = runSDK(config);
|
|
27
|
+
if (!state) return;
|
|
28
|
+
|
|
29
|
+
_initialised = true;
|
|
30
|
+
|
|
31
|
+
// Replay any calls that were queued before the script finished loading
|
|
32
|
+
_replayQueue();
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
identify(userId: string, traits?: Record<string, unknown>): void {
|
|
36
|
+
if (!_initialised) {
|
|
37
|
+
logger.warn('identify() called before init() — call will be ignored.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setBatcherUserId(userId);
|
|
41
|
+
logger.log(`identify: ${userId}`, traits);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
track(eventName: string, properties?: Record<string, unknown>): void {
|
|
45
|
+
if (!_initialised) {
|
|
46
|
+
logger.warn('track() called before init() — call will be ignored.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
pushEvent('step_action_taken', { properties: { eventName, ...(properties ?? {}) } });
|
|
50
|
+
checkCompletionGoal(eventName);
|
|
51
|
+
logger.log(`track: ${eventName}`, properties);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Async snippet queue replay
|
|
57
|
+
// Product teams paste a stub into their HTML before the SDK loads.
|
|
58
|
+
// The stub queues calls in window.OnboardMe._q.
|
|
59
|
+
// When the real SDK loads, we replay everything that was buffered.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
type QueueEntry = [string, IArguments | unknown[]];
|
|
63
|
+
|
|
64
|
+
interface OnboardMeStub {
|
|
65
|
+
_q?: QueueEntry[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _replayQueue(): void {
|
|
69
|
+
const stub = (window as unknown as { OnboardMe?: OnboardMeStub }).OnboardMe;
|
|
70
|
+
const queue = stub?._q;
|
|
71
|
+
if (!Array.isArray(queue) || queue.length === 0) return;
|
|
72
|
+
|
|
73
|
+
logger.log(`replaying ${queue.length} queued call(s)`);
|
|
74
|
+
|
|
75
|
+
for (const [method, args] of queue) {
|
|
76
|
+
if (method === 'identify') {
|
|
77
|
+
OnboardMe.identify(args[0] as string, args[1] as Record<string, unknown>);
|
|
78
|
+
} else if (method === 'track') {
|
|
79
|
+
OnboardMe.track(args[0] as string, args[1] as Record<string, unknown>);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Clear the queue so replayed calls are not re-run
|
|
84
|
+
if (stub) stub._q = [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Replace the stub on window with the real SDK
|
|
88
|
+
(window as unknown as { OnboardMe: typeof OnboardMe }).OnboardMe = OnboardMe;
|
|
89
|
+
|
|
90
|
+
export default OnboardMe;
|
|
91
|
+
|
|
92
|
+
/** Resets the index-level initialised flag — only used in tests. */
|
|
93
|
+
export function _resetIndex(): void {
|
|
94
|
+
_initialised = false;
|
|
95
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// DOM collector (Architecture v2 — Phase B).
|
|
2
|
+
//
|
|
3
|
+
// Walks the host page's DOM and extracts a normalised, AI-readable summary
|
|
4
|
+
// of the UI: headings, buttons, links, form inputs, navigation, images.
|
|
5
|
+
// Output is the SnapshotPayload the API expects at POST /v1/code-sources/snapshot.
|
|
6
|
+
//
|
|
7
|
+
// Constraints:
|
|
8
|
+
// - Read-only — never mutates the host page DOM
|
|
9
|
+
// - Bounded — caps element count and text length per element
|
|
10
|
+
// - Resilient — wrapped in try/catch where a malformed DOM might throw
|
|
11
|
+
|
|
12
|
+
export type SnapshotRole =
|
|
13
|
+
| 'heading'
|
|
14
|
+
| 'button'
|
|
15
|
+
| 'link'
|
|
16
|
+
| 'input'
|
|
17
|
+
| 'form'
|
|
18
|
+
| 'nav'
|
|
19
|
+
| 'image'
|
|
20
|
+
| 'other'
|
|
21
|
+
|
|
22
|
+
export interface SnapshotElement {
|
|
23
|
+
selector: string
|
|
24
|
+
role: SnapshotRole
|
|
25
|
+
text?: string
|
|
26
|
+
attributes?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SnapshotPayload {
|
|
30
|
+
pageUrl: string
|
|
31
|
+
pageTitle: string
|
|
32
|
+
collectedAt: number
|
|
33
|
+
elements: SnapshotElement[]
|
|
34
|
+
meta?: {
|
|
35
|
+
userAgent?: string
|
|
36
|
+
viewport?: { width: number; height: number }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MAX_ELEMENTS = 500
|
|
41
|
+
const MAX_TEXT_LEN = 200
|
|
42
|
+
|
|
43
|
+
// Roles in the order we collect them — earlier roles win when an element
|
|
44
|
+
// could match multiple categories (e.g. <a role="button"> is a button).
|
|
45
|
+
const COLLECTORS: Array<{ selector: string; role: SnapshotRole }> = [
|
|
46
|
+
{ selector: 'h1, h2, h3, h4, h5, h6', role: 'heading' },
|
|
47
|
+
{ selector: 'button, [role="button"]', role: 'button' },
|
|
48
|
+
{ selector: 'a[href]', role: 'link' },
|
|
49
|
+
{ selector: 'input, textarea, select', role: 'input' },
|
|
50
|
+
{ selector: 'form', role: 'form' },
|
|
51
|
+
{ selector: 'nav, [role="navigation"]', role: 'nav' },
|
|
52
|
+
{ selector: 'img[alt]', role: 'image' },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
const ATTRIBUTES_BY_ROLE: Record<SnapshotRole, string[]> = {
|
|
56
|
+
heading: [],
|
|
57
|
+
button: ['type', 'name', 'aria-label', 'data-testid'],
|
|
58
|
+
link: ['href', 'aria-label', 'data-testid'],
|
|
59
|
+
input: ['type', 'name', 'placeholder', 'aria-label', 'data-testid'],
|
|
60
|
+
form: ['name', 'action', 'method'],
|
|
61
|
+
nav: ['aria-label'],
|
|
62
|
+
image: ['alt', 'src'],
|
|
63
|
+
other: [],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function collectSnapshot(doc: Document = document, win: Window = window): SnapshotPayload {
|
|
67
|
+
const seen = new Set<Element>()
|
|
68
|
+
const elements: SnapshotElement[] = []
|
|
69
|
+
|
|
70
|
+
for (const { selector, role } of COLLECTORS) {
|
|
71
|
+
const nodes = safeQueryAll(doc, selector)
|
|
72
|
+
for (const node of nodes) {
|
|
73
|
+
if (seen.has(node)) continue
|
|
74
|
+
if (!isVisible(node, win)) continue
|
|
75
|
+
seen.add(node)
|
|
76
|
+
|
|
77
|
+
const el: SnapshotElement = {
|
|
78
|
+
selector: buildSelector(node),
|
|
79
|
+
role,
|
|
80
|
+
}
|
|
81
|
+
const text = extractText(node)
|
|
82
|
+
if (text) el.text = text
|
|
83
|
+
const attrs = extractAttributes(node, role)
|
|
84
|
+
if (Object.keys(attrs).length > 0) el.attributes = attrs
|
|
85
|
+
|
|
86
|
+
elements.push(el)
|
|
87
|
+
if (elements.length >= MAX_ELEMENTS) break
|
|
88
|
+
}
|
|
89
|
+
if (elements.length >= MAX_ELEMENTS) break
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
pageUrl: win.location?.href ?? '',
|
|
94
|
+
pageTitle: doc.title ?? '',
|
|
95
|
+
collectedAt: Date.now(),
|
|
96
|
+
elements,
|
|
97
|
+
meta: {
|
|
98
|
+
userAgent: win.navigator?.userAgent,
|
|
99
|
+
viewport: {
|
|
100
|
+
width: win.innerWidth ?? 0,
|
|
101
|
+
height: win.innerHeight ?? 0,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Selector synthesis ──────────────────────────────────────────────────────
|
|
108
|
+
// Prefers stable identifiers in this order:
|
|
109
|
+
// 1. data-testid attribute
|
|
110
|
+
// 2. id attribute (if it doesn't look auto-generated)
|
|
111
|
+
// 3. tag + nth-of-type fallback (always present)
|
|
112
|
+
// Auto-generated id heuristic: contains digits beyond a small constant or
|
|
113
|
+
// matches react/__-style prefixes — these change across renders.
|
|
114
|
+
|
|
115
|
+
export function buildSelector(el: Element): string {
|
|
116
|
+
const testId = el.getAttribute('data-testid')
|
|
117
|
+
if (testId) return `[data-testid="${cssEscape(testId)}"]`
|
|
118
|
+
|
|
119
|
+
const id = el.getAttribute('id')
|
|
120
|
+
if (id && !looksAutoGenerated(id)) return `#${cssEscape(id)}`
|
|
121
|
+
|
|
122
|
+
const tag = el.tagName.toLowerCase()
|
|
123
|
+
|
|
124
|
+
// nth-of-type relative to siblings of same tag — gives a unique selector
|
|
125
|
+
// even when no IDs/classes are available.
|
|
126
|
+
const parent = el.parentElement
|
|
127
|
+
if (!parent) return tag
|
|
128
|
+
|
|
129
|
+
const siblings = Array.from(parent.children).filter((c) => c.tagName === el.tagName)
|
|
130
|
+
if (siblings.length === 1) return tag
|
|
131
|
+
const idx = siblings.indexOf(el) + 1
|
|
132
|
+
return `${tag}:nth-of-type(${idx})`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function looksAutoGenerated(id: string): boolean {
|
|
136
|
+
// React/Vue framework patterns: __, react-, _stable_, long digit runs
|
|
137
|
+
if (id.startsWith('__') || id.startsWith('_react')) return true
|
|
138
|
+
const digitRun = id.match(/\d{6,}/)
|
|
139
|
+
return digitRun !== null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// CSS.escape isn't available everywhere — a minimal-shim covers identifier chars.
|
|
143
|
+
function cssEscape(value: string): string {
|
|
144
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Text extraction ─────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function extractText(el: Element): string {
|
|
150
|
+
const raw = (el as HTMLElement).innerText ?? el.textContent ?? ''
|
|
151
|
+
const trimmed = raw.replace(/\s+/g, ' ').trim()
|
|
152
|
+
if (trimmed.length === 0) return ''
|
|
153
|
+
if (trimmed.length <= MAX_TEXT_LEN) return trimmed
|
|
154
|
+
return trimmed.slice(0, MAX_TEXT_LEN - 1) + '…'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Attribute extraction ────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function extractAttributes(el: Element, role: SnapshotRole): Record<string, string> {
|
|
160
|
+
const out: Record<string, string> = {}
|
|
161
|
+
for (const name of ATTRIBUTES_BY_ROLE[role]) {
|
|
162
|
+
const val = el.getAttribute(name)
|
|
163
|
+
if (val !== null && val.length > 0) out[name] = val.slice(0, 200)
|
|
164
|
+
}
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Visibility check ────────────────────────────────────────────────────────
|
|
169
|
+
// Skip hidden elements — they're not part of the actual user-visible UI.
|
|
170
|
+
|
|
171
|
+
function isVisible(el: Element, win: Window): boolean {
|
|
172
|
+
// jsdom's getComputedStyle returns sensible defaults; in production browsers
|
|
173
|
+
// it's authoritative. Either way, treat unknowns as visible (safer for
|
|
174
|
+
// capture; if we miss showing something, the user can always re-collect).
|
|
175
|
+
try {
|
|
176
|
+
const style = win.getComputedStyle?.(el as HTMLElement)
|
|
177
|
+
if (!style) return true
|
|
178
|
+
if (style.display === 'none') return false
|
|
179
|
+
if (style.visibility === 'hidden') return false
|
|
180
|
+
if (style.opacity === '0') return false
|
|
181
|
+
} catch {
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function safeQueryAll(doc: Document, selector: string): Element[] {
|
|
188
|
+
try {
|
|
189
|
+
return Array.from(doc.querySelectorAll(selector))
|
|
190
|
+
} catch {
|
|
191
|
+
return []
|
|
192
|
+
}
|
|
193
|
+
}
|