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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import type { FlowConfig } from '@onboardme/types'
|
|
3
|
+
import {
|
|
4
|
+
fetchFlowsFromAPI,
|
|
5
|
+
loadFlowsFromCache,
|
|
6
|
+
saveFlowsToCache,
|
|
7
|
+
fetchFlowsWithFallback,
|
|
8
|
+
startFlowsPolling,
|
|
9
|
+
stopFlowsPolling,
|
|
10
|
+
getCachedFlows,
|
|
11
|
+
_clearFlowsCache,
|
|
12
|
+
} from '../core/api-flows'
|
|
13
|
+
|
|
14
|
+
describe('Week 7 Day 5 — API Flows Integration', () => {
|
|
15
|
+
const mockFlows: FlowConfig[] = [
|
|
16
|
+
{
|
|
17
|
+
id: 'flow-1',
|
|
18
|
+
name: 'Welcome Flow',
|
|
19
|
+
completionGoal: 'onboarding_complete',
|
|
20
|
+
priority: 1,
|
|
21
|
+
steps: [
|
|
22
|
+
{
|
|
23
|
+
id: 'step-1',
|
|
24
|
+
type: 'modal',
|
|
25
|
+
order: 1,
|
|
26
|
+
title: 'Welcome',
|
|
27
|
+
body: 'Test body',
|
|
28
|
+
trigger: { type: 'page_load' },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'flow-2',
|
|
34
|
+
name: 'Checklist Flow',
|
|
35
|
+
completionGoal: 'checklist_complete',
|
|
36
|
+
priority: 2,
|
|
37
|
+
steps: [
|
|
38
|
+
{
|
|
39
|
+
id: 'step-2',
|
|
40
|
+
type: 'checklist',
|
|
41
|
+
order: 1,
|
|
42
|
+
title: 'Checklist',
|
|
43
|
+
body: 'Complete these items',
|
|
44
|
+
items: [],
|
|
45
|
+
trigger: { type: 'page_load' },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const mockResponse = {
|
|
52
|
+
flows: mockFlows,
|
|
53
|
+
checksumHash: 'abc12345',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const testCacheKey = 'test_cache_key'
|
|
57
|
+
const testEndpoint = 'http://localhost:3000'
|
|
58
|
+
const testApiKey = 'test-api-key-123'
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
_clearFlowsCache()
|
|
62
|
+
localStorage.clear()
|
|
63
|
+
vi.clearAllMocks()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
stopFlowsPolling()
|
|
68
|
+
localStorage.clear()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('fetchFlowsFromAPI', () => {
|
|
72
|
+
it('should fetch flows from API successfully', async () => {
|
|
73
|
+
const fetchMock = vi.fn()
|
|
74
|
+
global.fetch = fetchMock.mockResolvedValueOnce({
|
|
75
|
+
ok: true,
|
|
76
|
+
json: async () => mockResponse,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const result = await fetchFlowsFromAPI(testEndpoint, testApiKey)
|
|
80
|
+
|
|
81
|
+
expect(result).toEqual(mockResponse)
|
|
82
|
+
expect(fetchMock).toHaveBeenCalledWith(`${testEndpoint}/v1/flows`, {
|
|
83
|
+
method: 'GET',
|
|
84
|
+
headers: {
|
|
85
|
+
'x-api-key': testApiKey,
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
},
|
|
88
|
+
signal: expect.any(AbortSignal),
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should return null on HTTP error', async () => {
|
|
93
|
+
const fetchMock = vi.fn()
|
|
94
|
+
global.fetch = fetchMock.mockResolvedValueOnce({
|
|
95
|
+
ok: false,
|
|
96
|
+
status: 401,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const result = await fetchFlowsFromAPI(testEndpoint, testApiKey)
|
|
100
|
+
|
|
101
|
+
expect(result).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should return null on network error', async () => {
|
|
105
|
+
const fetchMock = vi.fn()
|
|
106
|
+
global.fetch = fetchMock.mockRejectedValueOnce(new Error('Network error'))
|
|
107
|
+
|
|
108
|
+
const result = await fetchFlowsFromAPI(testEndpoint, testApiKey)
|
|
109
|
+
|
|
110
|
+
expect(result).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should return null on timeout (10s)', async () => {
|
|
114
|
+
const fetchMock = vi.fn()
|
|
115
|
+
global.fetch = fetchMock.mockImplementationOnce(
|
|
116
|
+
() =>
|
|
117
|
+
new Promise(() => {
|
|
118
|
+
// Never resolves — simulates timeout
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Use Promise.race to enforce timeout behavior without waiting 10s
|
|
123
|
+
const result = await Promise.race([
|
|
124
|
+
fetchFlowsFromAPI(testEndpoint, testApiKey),
|
|
125
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 100)),
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
expect(result).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('localStorage cache', () => {
|
|
133
|
+
it('should save flows to cache', () => {
|
|
134
|
+
saveFlowsToCache(testCacheKey, mockFlows, 'hash123')
|
|
135
|
+
|
|
136
|
+
const cached = localStorage.getItem(testCacheKey)
|
|
137
|
+
expect(cached).toBeTruthy()
|
|
138
|
+
|
|
139
|
+
const parsed = JSON.parse(cached!)
|
|
140
|
+
expect(parsed.flows).toEqual(mockFlows)
|
|
141
|
+
expect(parsed.checksumHash).toBe('hash123')
|
|
142
|
+
expect(parsed.timestamp).toBeLessThanOrEqual(Date.now())
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should load flows from cache', () => {
|
|
146
|
+
saveFlowsToCache(testCacheKey, mockFlows, 'hash123')
|
|
147
|
+
|
|
148
|
+
const loaded = loadFlowsFromCache(testCacheKey)
|
|
149
|
+
|
|
150
|
+
expect(loaded).toBeTruthy()
|
|
151
|
+
expect(loaded!.flows).toEqual(mockFlows)
|
|
152
|
+
expect(loaded!.checksumHash).toBe('hash123')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should return null for missing cache', () => {
|
|
156
|
+
const loaded = loadFlowsFromCache('nonexistent_key')
|
|
157
|
+
|
|
158
|
+
expect(loaded).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should return null for expired cache (1h TTL)', () => {
|
|
162
|
+
// Manually create expired cache
|
|
163
|
+
const expiredData = {
|
|
164
|
+
flows: mockFlows,
|
|
165
|
+
checksumHash: 'hash123',
|
|
166
|
+
timestamp: Date.now() - 3600000 - 1000, // 1h + 1s ago
|
|
167
|
+
}
|
|
168
|
+
localStorage.setItem(testCacheKey, JSON.stringify(expiredData))
|
|
169
|
+
|
|
170
|
+
const loaded = loadFlowsFromCache(testCacheKey)
|
|
171
|
+
|
|
172
|
+
expect(loaded).toBeNull()
|
|
173
|
+
expect(localStorage.getItem(testCacheKey)).toBeNull() // Should be cleared
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should handle corrupted cache gracefully', () => {
|
|
177
|
+
localStorage.setItem(testCacheKey, 'corrupted json {{{')
|
|
178
|
+
|
|
179
|
+
const loaded = loadFlowsFromCache(testCacheKey)
|
|
180
|
+
|
|
181
|
+
expect(loaded).toBeNull()
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('fetchFlowsWithFallback', () => {
|
|
186
|
+
it('should fetch from API and cache result', async () => {
|
|
187
|
+
const fetchMock = vi.fn()
|
|
188
|
+
global.fetch = fetchMock.mockResolvedValueOnce({
|
|
189
|
+
ok: true,
|
|
190
|
+
json: async () => mockResponse,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const result = await fetchFlowsWithFallback(testEndpoint, testApiKey, testCacheKey)
|
|
194
|
+
|
|
195
|
+
expect(result).toEqual(mockResponse)
|
|
196
|
+
expect(localStorage.getItem(testCacheKey)).toBeTruthy()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should fall back to cache on API failure', async () => {
|
|
200
|
+
// Pre-populate cache
|
|
201
|
+
saveFlowsToCache(testCacheKey, mockFlows, 'cached-hash')
|
|
202
|
+
|
|
203
|
+
// Mock API failure
|
|
204
|
+
const fetchMock = vi.fn()
|
|
205
|
+
global.fetch = fetchMock.mockRejectedValueOnce(new Error('API down'))
|
|
206
|
+
|
|
207
|
+
const result = await fetchFlowsWithFallback(testEndpoint, testApiKey, testCacheKey)
|
|
208
|
+
|
|
209
|
+
expect(result.flows).toEqual(mockFlows)
|
|
210
|
+
expect(result.checksumHash).toBe('cached-hash')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should return empty array on all failures', async () => {
|
|
214
|
+
// No cache
|
|
215
|
+
const fetchMock = vi.fn()
|
|
216
|
+
global.fetch = fetchMock.mockRejectedValueOnce(new Error('API down'))
|
|
217
|
+
|
|
218
|
+
const result = await fetchFlowsWithFallback(testEndpoint, testApiKey, testCacheKey)
|
|
219
|
+
|
|
220
|
+
expect(result.flows).toEqual([])
|
|
221
|
+
expect(result.checksumHash).toBe('')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('Flow polling', () => {
|
|
226
|
+
it('should call callback when checksum changes', async () => {
|
|
227
|
+
const callback = vi.fn()
|
|
228
|
+
let pollCount = 0
|
|
229
|
+
|
|
230
|
+
const fetchMock = vi.fn().mockImplementation(async () => {
|
|
231
|
+
pollCount++
|
|
232
|
+
if (pollCount === 1) {
|
|
233
|
+
return { ok: true, json: async () => ({ flows: mockFlows, checksumHash: 'hash1' }) }
|
|
234
|
+
}
|
|
235
|
+
// Second poll returns different hash
|
|
236
|
+
return { ok: true, json: async () => ({ flows: mockFlows, checksumHash: 'hash2' }) }
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
global.fetch = fetchMock
|
|
240
|
+
|
|
241
|
+
startFlowsPolling(testEndpoint, testApiKey, testCacheKey, callback)
|
|
242
|
+
|
|
243
|
+
// Wait for immediate check
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
245
|
+
expect(callback).toHaveBeenCalledWith(mockFlows, 'hash1')
|
|
246
|
+
|
|
247
|
+
// Clear mock and wait for it to be called again
|
|
248
|
+
callback.mockClear()
|
|
249
|
+
// Manually trigger second poll by updating mock
|
|
250
|
+
pollCount = 1
|
|
251
|
+
fetchMock.mockResolvedValueOnce({
|
|
252
|
+
ok: true,
|
|
253
|
+
json: async () => ({ flows: mockFlows, checksumHash: 'hash2' }),
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
stopFlowsPolling()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('should not call callback if hash unchanged', async () => {
|
|
260
|
+
const callback = vi.fn()
|
|
261
|
+
|
|
262
|
+
const fetchMock = vi.fn()
|
|
263
|
+
global.fetch = fetchMock.mockResolvedValue({
|
|
264
|
+
ok: true,
|
|
265
|
+
json: async () => ({ flows: mockFlows, checksumHash: 'same-hash' }),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
startFlowsPolling(testEndpoint, testApiKey, testCacheKey, callback)
|
|
269
|
+
|
|
270
|
+
// Wait for initial check
|
|
271
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
272
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
273
|
+
|
|
274
|
+
// Callback should not be called again (hash is same)
|
|
275
|
+
callback.mockClear()
|
|
276
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
277
|
+
expect(callback).not.toHaveBeenCalled()
|
|
278
|
+
|
|
279
|
+
stopFlowsPolling()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should stop polling cleanly', async () => {
|
|
283
|
+
const callback = vi.fn()
|
|
284
|
+
const fetchMock = vi.fn()
|
|
285
|
+
global.fetch = fetchMock.mockResolvedValue({
|
|
286
|
+
ok: true,
|
|
287
|
+
json: async () => mockResponse,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
startFlowsPolling(testEndpoint, testApiKey, testCacheKey, callback)
|
|
291
|
+
|
|
292
|
+
// Stop immediately
|
|
293
|
+
stopFlowsPolling()
|
|
294
|
+
|
|
295
|
+
// Polling should be stopped
|
|
296
|
+
expect(getCachedFlows()).toBeNull() // Reset was called
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('Integration', () => {
|
|
301
|
+
it('should handle multiple save/load cycles', () => {
|
|
302
|
+
const hash1 = 'hash1'
|
|
303
|
+
saveFlowsToCache(testCacheKey, mockFlows, hash1)
|
|
304
|
+
|
|
305
|
+
let loaded = loadFlowsFromCache(testCacheKey)
|
|
306
|
+
expect(loaded!.checksumHash).toBe(hash1)
|
|
307
|
+
|
|
308
|
+
// Update cache
|
|
309
|
+
const hash2 = 'hash2'
|
|
310
|
+
saveFlowsToCache(testCacheKey, mockFlows, hash2)
|
|
311
|
+
|
|
312
|
+
loaded = loadFlowsFromCache(testCacheKey)
|
|
313
|
+
expect(loaded!.checksumHash).toBe(hash2)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('should preserve flow structure through cache cycle', () => {
|
|
317
|
+
const originalFlows: FlowConfig[] = [
|
|
318
|
+
{
|
|
319
|
+
id: 'flow-test',
|
|
320
|
+
name: 'Test Flow',
|
|
321
|
+
completionGoal: 'test_complete',
|
|
322
|
+
priority: 1,
|
|
323
|
+
steps: [
|
|
324
|
+
{
|
|
325
|
+
id: 'step-1',
|
|
326
|
+
type: 'modal',
|
|
327
|
+
order: 1,
|
|
328
|
+
title: 'Step 1',
|
|
329
|
+
body: 'Body 1',
|
|
330
|
+
trigger: { type: 'page_load' },
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'step-2',
|
|
334
|
+
type: 'checklist',
|
|
335
|
+
order: 2,
|
|
336
|
+
title: 'Step 2',
|
|
337
|
+
body: 'Body 2',
|
|
338
|
+
items: [],
|
|
339
|
+
trigger: { type: 'page_load' },
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
saveFlowsToCache(testCacheKey, originalFlows, 'hash-test')
|
|
346
|
+
const loaded = loadFlowsFromCache(testCacheKey)
|
|
347
|
+
|
|
348
|
+
expect(loaded!.flows).toEqual(originalFlows)
|
|
349
|
+
expect(loaded!.flows[0].steps).toHaveLength(2)
|
|
350
|
+
expect(loaded!.flows[0].steps[0].type).toBe('modal')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ProductContext } from '@onboardme/types';
|
|
2
|
+
|
|
3
|
+
const MAX_HEADINGS = 10;
|
|
4
|
+
const MAX_NAV_LINKS = 10;
|
|
5
|
+
const MAX_BUTTONS = 15;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extracts unique, trimmed, non-empty text from a NodeList up to `max` entries.
|
|
9
|
+
*/
|
|
10
|
+
function uniqueText(elements: NodeListOf<Element>, max: number): string[] {
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
const result: string[] = [];
|
|
13
|
+
for (const el of elements) {
|
|
14
|
+
const text = (el.textContent ?? '').trim();
|
|
15
|
+
if (text && !seen.has(text)) {
|
|
16
|
+
seen.add(text);
|
|
17
|
+
result.push(text);
|
|
18
|
+
if (result.length >= max) break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collects a DOM fingerprint from the current page.
|
|
26
|
+
* Used by the AI flow-generator to give Claude context about the product.
|
|
27
|
+
*/
|
|
28
|
+
export function collectContext(): ProductContext {
|
|
29
|
+
const title = document.title ?? '';
|
|
30
|
+
|
|
31
|
+
const headings = uniqueText(
|
|
32
|
+
document.querySelectorAll('h1, h2, h3'),
|
|
33
|
+
MAX_HEADINGS,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const navLinks = uniqueText(
|
|
37
|
+
document.querySelectorAll('nav a'),
|
|
38
|
+
MAX_NAV_LINKS,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const buttons = uniqueText(
|
|
42
|
+
document.querySelectorAll('button, [role="button"]'),
|
|
43
|
+
MAX_BUTTONS,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return { title, headings, navLinks, buttons };
|
|
47
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { FlowConfig, ProductContext } from '@onboardme/types';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
5
|
+
const TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
interface CacheEntry {
|
|
8
|
+
flows: FlowConfig[];
|
|
9
|
+
cachedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function cacheKey(productId: string): string {
|
|
13
|
+
return `onboardme_autogen_${productId}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Returns cached flows if present and not expired, otherwise null. */
|
|
17
|
+
export function loadCache(productId: string, ttlMs: number): FlowConfig[] | null {
|
|
18
|
+
try {
|
|
19
|
+
const raw = localStorage.getItem(cacheKey(productId));
|
|
20
|
+
if (!raw) return null;
|
|
21
|
+
const entry: CacheEntry = JSON.parse(raw);
|
|
22
|
+
if (Date.now() - entry.cachedAt > ttlMs) return null;
|
|
23
|
+
return entry.flows;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Persists flows to localStorage. Silently no-ops on quota errors. */
|
|
30
|
+
export function saveCache(productId: string, flows: FlowConfig[]): void {
|
|
31
|
+
try {
|
|
32
|
+
const entry: CacheEntry = { flows, cachedAt: Date.now() };
|
|
33
|
+
localStorage.setItem(cacheKey(productId), JSON.stringify(entry));
|
|
34
|
+
} catch {
|
|
35
|
+
logger.warn('auto-generate: localStorage quota exceeded — cache not saved');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fetches AI-generated flows from the flow-generator server.
|
|
41
|
+
* Returns the cached result if fresh, otherwise POSTs the ProductContext
|
|
42
|
+
* and caches the response. Never throws — returns [] on any error.
|
|
43
|
+
*/
|
|
44
|
+
export async function fetchGeneratedFlows(
|
|
45
|
+
endpoint: string,
|
|
46
|
+
productId: string,
|
|
47
|
+
context: ProductContext,
|
|
48
|
+
ttlMs = DEFAULT_CACHE_TTL_MS,
|
|
49
|
+
): Promise<FlowConfig[]> {
|
|
50
|
+
const cached = loadCache(productId, ttlMs);
|
|
51
|
+
if (cached) return cached;
|
|
52
|
+
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timerId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(endpoint, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ productId, context }),
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
logger.warn(`auto-generate: server responded ${res.status}`);
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data: unknown = await res.json();
|
|
70
|
+
const flows: FlowConfig[] =
|
|
71
|
+
data !== null &&
|
|
72
|
+
typeof data === 'object' &&
|
|
73
|
+
'flows' in data &&
|
|
74
|
+
Array.isArray((data as { flows: unknown }).flows)
|
|
75
|
+
? ((data as { flows: FlowConfig[] }).flows)
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
saveCache(productId, flows);
|
|
79
|
+
return flows;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.warn(`auto-generate: fetch failed — ${(err as Error).message}`);
|
|
82
|
+
return [];
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timerId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Merges manually configured flows with AI-generated flows.
|
|
90
|
+
* Generated flows whose id already exists in manual are skipped.
|
|
91
|
+
* Manual flows always take precedence and appear first.
|
|
92
|
+
*/
|
|
93
|
+
export function mergeFlows(manual: FlowConfig[], generated: FlowConfig[]): FlowConfig[] {
|
|
94
|
+
const existingIds = new Set(manual.map((f) => f.id));
|
|
95
|
+
const novel = generated.filter((f) => !existingIds.has(f.id));
|
|
96
|
+
return [...manual, ...novel];
|
|
97
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Browser IIFE entry — re-exports only the default SDK object.
|
|
2
|
+
// Named test helpers (_resetIndex, _resetSDK) are intentionally excluded so
|
|
3
|
+
// Rollup can use the single-default-export IIFE pattern, which sets
|
|
4
|
+
// window.OnboardMe = <SDK object> directly (no { default: sdk } wrapper).
|
|
5
|
+
export { default } from './index.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const CELEBRATION_CSS = `
|
|
2
|
+
.om-celebration {
|
|
3
|
+
position: fixed;
|
|
4
|
+
top: 1.25rem;
|
|
5
|
+
left: 50%;
|
|
6
|
+
transform: translateX(-50%);
|
|
7
|
+
background: #16a34a;
|
|
8
|
+
color: #ffffff;
|
|
9
|
+
font-family: system-ui, sans-serif;
|
|
10
|
+
font-size: 1rem;
|
|
11
|
+
font-weight: 600;
|
|
12
|
+
padding: 0.75rem 1.5rem;
|
|
13
|
+
border-radius: 0.5rem;
|
|
14
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
15
|
+
z-index: 999999;
|
|
16
|
+
text-align: center;
|
|
17
|
+
white-space: nowrap;
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Renders a "🎉 You're all set!" celebration banner inside the shadow root.
|
|
23
|
+
* The banner auto-dismisses after 3 seconds.
|
|
24
|
+
*/
|
|
25
|
+
export function showCelebration(shadowRoot: ShadowRoot): void {
|
|
26
|
+
// Inject styles if not already present
|
|
27
|
+
if (!shadowRoot.querySelector('#om-celebration-styles')) {
|
|
28
|
+
const style = document.createElement('style');
|
|
29
|
+
style.id = 'om-celebration-styles';
|
|
30
|
+
style.textContent = CELEBRATION_CSS;
|
|
31
|
+
shadowRoot.appendChild(style);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const banner = document.createElement('div');
|
|
35
|
+
banner.className = 'om-celebration';
|
|
36
|
+
banner.setAttribute('role', 'status');
|
|
37
|
+
banner.setAttribute('aria-live', 'polite');
|
|
38
|
+
banner.textContent = "🎉 You're all set!";
|
|
39
|
+
shadowRoot.appendChild(banner);
|
|
40
|
+
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
banner.remove();
|
|
43
|
+
}, 3000);
|
|
44
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checklist CSS as an injectable string for the Shadow DOM.
|
|
3
|
+
* Source of truth: checklist.css (kept alongside for readability/tooling).
|
|
4
|
+
*/
|
|
5
|
+
export const CHECKLIST_CSS = `
|
|
6
|
+
.om-checklist {
|
|
7
|
+
position: fixed;
|
|
8
|
+
bottom: 24px;
|
|
9
|
+
right: 24px;
|
|
10
|
+
width: 320px;
|
|
11
|
+
background: #ffffff;
|
|
12
|
+
border-radius: 12px;
|
|
13
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
14
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
15
|
+
font-size: 14px;
|
|
16
|
+
color: #1a1a2e;
|
|
17
|
+
z-index: 9999;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
transition: width 0.2s ease, height 0.2s ease;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.om-checklist__header {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
padding: 14px 16px 10px;
|
|
27
|
+
border-bottom: 1px solid #f0f0f0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.om-checklist__title {
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
font-size: 14px;
|
|
33
|
+
color: #1a1a2e;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.om-checklist__collapse-btn {
|
|
37
|
+
background: none;
|
|
38
|
+
border: none;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
font-size: 18px;
|
|
41
|
+
color: #888;
|
|
42
|
+
padding: 0 2px;
|
|
43
|
+
line-height: 1;
|
|
44
|
+
transition: color 0.15s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.om-checklist__collapse-btn:hover {
|
|
48
|
+
color: #1a1a2e;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.om-checklist__progress {
|
|
52
|
+
padding: 10px 16px 6px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.om-checklist__progress-bar {
|
|
56
|
+
width: 100%;
|
|
57
|
+
height: 6px;
|
|
58
|
+
background: #ebebeb;
|
|
59
|
+
border-radius: 3px;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
margin-bottom: 6px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.om-checklist__progress-bar-fill {
|
|
65
|
+
height: 100%;
|
|
66
|
+
background: #4f46e5;
|
|
67
|
+
border-radius: 3px;
|
|
68
|
+
transition: width 0.35s ease;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.om-checklist__progress-label {
|
|
72
|
+
font-size: 12px;
|
|
73
|
+
color: #888;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.om-checklist__items {
|
|
77
|
+
list-style: none;
|
|
78
|
+
margin: 0;
|
|
79
|
+
padding: 6px 0 10px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.om-checklist__item {
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: 10px;
|
|
86
|
+
padding: 8px 16px;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
transition: background 0.1s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.om-checklist__item:hover {
|
|
92
|
+
background: #f8f8fb;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.om-checklist__item-check {
|
|
96
|
+
flex-shrink: 0;
|
|
97
|
+
width: 18px;
|
|
98
|
+
height: 18px;
|
|
99
|
+
border: 2px solid #d1d5db;
|
|
100
|
+
border-radius: 50%;
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
justify-content: center;
|
|
104
|
+
font-size: 10px;
|
|
105
|
+
color: transparent;
|
|
106
|
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.om-checklist__item-label {
|
|
110
|
+
flex: 1;
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
color: #1a1a2e;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.om-checklist__item--done .om-checklist__item-check {
|
|
116
|
+
background: #4f46e5;
|
|
117
|
+
border-color: #4f46e5;
|
|
118
|
+
color: #ffffff;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.om-checklist__item--done .om-checklist__item-label {
|
|
122
|
+
text-decoration: line-through;
|
|
123
|
+
color: #aaa;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.om-checklist__item--optional .om-checklist__item-label {
|
|
127
|
+
color: #888;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.om-badge {
|
|
131
|
+
flex-shrink: 0;
|
|
132
|
+
background: #f3f4f6;
|
|
133
|
+
color: #6b7280;
|
|
134
|
+
font-size: 11px;
|
|
135
|
+
font-weight: 500;
|
|
136
|
+
padding: 2px 7px;
|
|
137
|
+
border-radius: 10px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.om-checklist--collapsed {
|
|
141
|
+
width: auto;
|
|
142
|
+
height: auto;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.om-checklist--collapsed .om-checklist__progress,
|
|
146
|
+
.om-checklist--collapsed .om-checklist__items {
|
|
147
|
+
display: none;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.om-checklist--collapsed .om-checklist__header {
|
|
151
|
+
border-bottom: none;
|
|
152
|
+
padding: 10px 14px;
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.om-checklist--collapsed .om-checklist__collapse-btn {
|
|
157
|
+
display: none;
|
|
158
|
+
}
|
|
159
|
+
`;
|