@thelol3882/sapar 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -2
  3. package/dist/adapter-Y9yB3-q3.d.ts +14 -0
  4. package/dist/adapter-fFZPsINL.d.ts +14 -0
  5. package/dist/chunk-7B66QEMY.js +536 -0
  6. package/dist/chunk-7B66QEMY.js.map +1 -0
  7. package/dist/chunk-FM64UDKJ.js +893 -0
  8. package/dist/chunk-FM64UDKJ.js.map +1 -0
  9. package/dist/chunk-VC6XLWM3.js +176 -0
  10. package/dist/chunk-VC6XLWM3.js.map +1 -0
  11. package/dist/chunk-ZBVQGMR4.js +583 -0
  12. package/dist/chunk-ZBVQGMR4.js.map +1 -0
  13. package/dist/cli/index.js +1335 -0
  14. package/dist/core/index.d.ts +273 -0
  15. package/dist/core/index.js +71 -0
  16. package/dist/core/index.js.map +1 -0
  17. package/dist/expo/index.d.ts +189 -0
  18. package/dist/expo/index.js +39 -0
  19. package/dist/expo/index.js.map +1 -0
  20. package/dist/expo-router/index.d.ts +18 -0
  21. package/dist/expo-router/index.js +46 -0
  22. package/dist/expo-router/index.js.map +1 -0
  23. package/dist/next/index.d.ts +18 -0
  24. package/dist/next/index.js +49 -0
  25. package/dist/next/index.js.map +1 -0
  26. package/dist/presentation-CClsPRT6.d.ts +12 -0
  27. package/dist/react/index.d.ts +35 -0
  28. package/dist/react/index.js +29 -0
  29. package/dist/react/index.js.map +1 -0
  30. package/dist/rtk/index.d.ts +751 -0
  31. package/dist/rtk/index.js +114 -0
  32. package/dist/rtk/index.js.map +1 -0
  33. package/dist/stepCount-CTYYFWiV.d.ts +57 -0
  34. package/dist/types-CHD2pB5X.d.ts +192 -0
  35. package/dist/web/index.d.ts +112 -0
  36. package/dist/web/index.js +34 -0
  37. package/dist/web/index.js.map +1 -0
  38. package/package.json +147 -3
  39. package/templates/expo/AnnouncementModal.tsx +102 -0
  40. package/templates/expo/TourCard.tsx +112 -0
  41. package/templates/expo/WhatsNewButton.tsx +87 -0
  42. package/templates/expo/WhatsNewPanel.tsx +167 -0
  43. package/templates/mantine/AnnouncementModal.tsx +66 -0
  44. package/templates/mantine/TourCard.tsx +84 -0
  45. package/templates/mantine/WhatsNewButton.tsx +62 -0
  46. package/templates/mantine/WhatsNewPanel.tsx +129 -0
  47. package/templates/tailwind/AnnouncementModal.tsx +100 -0
  48. package/templates/tailwind/TourCard.tsx +69 -0
  49. package/templates/tailwind/WhatsNewButton.tsx +56 -0
  50. package/templates/tailwind/WhatsNewPanel.tsx +174 -0
@@ -0,0 +1,893 @@
1
+ // src/core/capping.ts
2
+ var SESSION_CAP_KEY = "sapar.session-interruption-spent";
3
+ function hasSpentSessionInterruption(adapter) {
4
+ return adapter.session.get(SESSION_CAP_KEY) !== null;
5
+ }
6
+ function spendSessionInterruption(adapter) {
7
+ adapter.session.set(SESSION_CAP_KEY, String(adapter.now()));
8
+ }
9
+
10
+ // src/core/routes.ts
11
+ function normalizeRoute(route, locales) {
12
+ const path = (route ?? "").split("#")[0].split("?")[0].trim();
13
+ const segments = path.split("/").filter((segment) => segment !== "");
14
+ if (segments.length > 0 && locales.includes(segments[0])) segments.shift();
15
+ return `/${segments.join("/")}`;
16
+ }
17
+ function routeMatches(pattern, route, locales) {
18
+ const trimmed = pattern.trim();
19
+ if (!pattern) return false;
20
+ if (trimmed === "*") return true;
21
+ const target = normalizeRoute(route, locales);
22
+ if (trimmed.endsWith("/*")) {
23
+ const prefix = normalizeRoute(trimmed.slice(0, -2), locales);
24
+ return prefix === "/" || target === prefix || target.startsWith(`${prefix}/`);
25
+ }
26
+ return normalizeRoute(trimmed, locales) === target;
27
+ }
28
+ function isValidRoutePattern(pattern) {
29
+ if (!pattern) return false;
30
+ if (pattern === "*") return true;
31
+ const body = pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern;
32
+ return !body.includes("*");
33
+ }
34
+
35
+ // src/core/steps.ts
36
+ var DEFAULT_ANCHOR_TIMEOUT_MS = 3e3;
37
+ var DEFAULT_PRESENT_ANCHOR_TIMEOUT_MS = 300;
38
+ var DEFAULT_AUTO_START_TIMEOUT_MS = 1e4;
39
+ function requiresNavigation(step, adapter, locales) {
40
+ if (step.route === void 0) return false;
41
+ return normalizeRoute(step.route, locales) !== normalizeRoute(adapter.currentRoute(), locales);
42
+ }
43
+ async function findMissingAnchors(steps, adapter, locales) {
44
+ async function isMissing(step) {
45
+ if (step.anchor === void 0 || requiresNavigation(step, adapter, locales)) return false;
46
+ return await adapter.resolveAnchor(step.anchor, { timeoutMs: 0 }) === null;
47
+ }
48
+ const missing = await Promise.all(steps.map(isMissing));
49
+ return steps.filter((_, index) => missing[index]).map((step) => step.key);
50
+ }
51
+
52
+ // src/core/machine.ts
53
+ var IDLE = { phase: "idle", tour: null, index: 0, handle: null, skipped: [], autoStarted: false };
54
+ var noop = () => {
55
+ };
56
+ function createTourMachine(adapter, events, options = {}) {
57
+ const anchorTimeoutMs = options.anchorTimeoutMs ?? DEFAULT_ANCHOR_TIMEOUT_MS;
58
+ const presentAnchorTimeoutMs = options.presentAnchorTimeoutMs ?? DEFAULT_PRESENT_ANCHOR_TIMEOUT_MS;
59
+ const locales = options.locales ?? [];
60
+ const listeners = /* @__PURE__ */ new Set();
61
+ let snapshot = IDLE;
62
+ let destroyed = false;
63
+ let stopListeningForPress = noop;
64
+ let transitionToken = 0;
65
+ function beginTransition() {
66
+ stopListeningForPress();
67
+ stopListeningForPress = noop;
68
+ return ++transitionToken;
69
+ }
70
+ function isCurrent(token) {
71
+ return !destroyed && token === transitionToken;
72
+ }
73
+ function commit(next2) {
74
+ snapshot = next2;
75
+ stopListeningForPress();
76
+ listenToCurrentStep();
77
+ listeners.forEach((listener) => listener());
78
+ }
79
+ function listenToCurrentStep() {
80
+ stopListeningForPress = listenForPress(snapshot);
81
+ }
82
+ function listenForPress({ phase, tour, index, handle }) {
83
+ if (phase !== "running" || tour?.steps[index]?.advanceOn !== "press" || !handle) return noop;
84
+ return handle.onPress(() => void next());
85
+ }
86
+ async function resolveTarget(step) {
87
+ let timeoutMs = presentAnchorTimeoutMs;
88
+ if (requiresNavigation(step, adapter, locales)) {
89
+ await adapter.navigate(step.route);
90
+ timeoutMs = anchorTimeoutMs;
91
+ }
92
+ if (step.anchor === void 0) return { handle: null };
93
+ const handle = await adapter.resolveAnchor(step.anchor, { timeoutMs });
94
+ return handle && { handle };
95
+ }
96
+ async function seek(token, tour, from, direction) {
97
+ const missed = [];
98
+ for (let index = from; ; index += direction) {
99
+ const step = tour.steps[index];
100
+ if (!step) return { kind: "exhausted" };
101
+ const target = await resolveTarget(step);
102
+ if (!isCurrent(token)) return null;
103
+ if (target) return { kind: "arrived", index, handle: target.handle, missed };
104
+ if (step.required) return { kind: "aborted" };
105
+ missed.push(step.key);
106
+ }
107
+ }
108
+ async function land(token, tour, arrival) {
109
+ const { index, handle, missed } = arrival;
110
+ if (handle) {
111
+ await adapter.scrollIntoView(handle, { reducedMotion: adapter.prefersReducedMotion() });
112
+ if (!isCurrent(token)) return "stopped";
113
+ }
114
+ const missingLater = await findMissingAnchors(tour.steps.slice(index + 1), adapter, locales);
115
+ if (!isCurrent(token)) return "stopped";
116
+ const known = /* @__PURE__ */ new Set([...snapshot.skipped, ...missed]);
117
+ const missingEarlier = tour.steps.slice(0, index).filter((step) => known.has(step.key)).map((step) => step.key);
118
+ const skipped = [...missingEarlier, ...missingLater];
119
+ commit({ phase: "running", tour, index, handle, skipped, autoStarted: snapshot.autoStarted });
120
+ return "landed";
121
+ }
122
+ async function travel(token, tour, from, direction) {
123
+ const found = await seek(token, tour, from, direction);
124
+ if (!found) return "stopped";
125
+ if (found.kind === "aborted") {
126
+ commit(IDLE);
127
+ return "stopped";
128
+ }
129
+ return found.kind === "exhausted" ? "exhausted" : land(token, tour, found);
130
+ }
131
+ async function guarded(token, transition) {
132
+ try {
133
+ await transition();
134
+ } catch (error) {
135
+ if (isCurrent(token)) commit(IDLE);
136
+ events.onError(error, "tour machine");
137
+ }
138
+ }
139
+ async function start(tour, { autoStarted }) {
140
+ if (destroyed) return;
141
+ const token = beginTransition();
142
+ commit({ ...IDLE, phase: "preparing", tour, autoStarted });
143
+ await guarded(token, async () => {
144
+ const outcome = await travel(token, tour, 0, 1);
145
+ if (outcome === "exhausted") commit(IDLE);
146
+ if (outcome === "landed") {
147
+ const { key, revision } = tour;
148
+ events.onProgress({ key, revision, status: "started", stepIndex: snapshot.index, autoStarted });
149
+ }
150
+ });
151
+ }
152
+ async function move(direction) {
153
+ const { phase, tour, index } = snapshot;
154
+ if (destroyed || phase !== "running" || !tour) return;
155
+ const token = beginTransition();
156
+ await guarded(token, async () => {
157
+ const outcome = await travel(token, tour, index + direction, direction);
158
+ if (outcome !== "exhausted") return;
159
+ if (direction === 1) finish();
160
+ else listenToCurrentStep();
161
+ });
162
+ }
163
+ const next = () => move(1);
164
+ const previous = () => move(-1);
165
+ function end(status) {
166
+ const { phase, tour, index, autoStarted } = snapshot;
167
+ if (destroyed || phase !== "running" || !tour) return;
168
+ beginTransition();
169
+ commit(IDLE);
170
+ events.onProgress({ key: tour.key, revision: tour.revision, status, stepIndex: index, autoStarted });
171
+ }
172
+ const finish = () => end("completed");
173
+ const skip = () => end("dismissed");
174
+ function destroy() {
175
+ if (destroyed) return;
176
+ beginTransition();
177
+ destroyed = true;
178
+ listeners.clear();
179
+ }
180
+ return {
181
+ subscribe(listener) {
182
+ if (destroyed) return noop;
183
+ listeners.add(listener);
184
+ return () => listeners.delete(listener);
185
+ },
186
+ getSnapshot: () => snapshot,
187
+ start,
188
+ next,
189
+ previous,
190
+ skip,
191
+ finish,
192
+ destroy
193
+ };
194
+ }
195
+
196
+ // src/core/controller.ts
197
+ function withUnread(inbox, keys, unread) {
198
+ const flipped = inbox.items.filter((item) => keys.has(item.key) && item.unread !== unread);
199
+ if (flipped.length === 0) return inbox;
200
+ const countDelta = flipped.filter((item) => item.tier !== "silent").length * (unread ? 1 : -1);
201
+ return {
202
+ ...inbox,
203
+ unreadCount: inbox.unreadCount + countDelta,
204
+ items: inbox.items.map((item) => flipped.includes(item) ? { ...item, unread } : item)
205
+ };
206
+ }
207
+ function withoutInterruption(inbox, announcementKeys) {
208
+ const { interruption } = inbox;
209
+ const matches = interruption?.kind === "announcement" && announcementKeys.includes(interruption.announcement.key);
210
+ return matches ? { ...inbox, interruption: null } : inbox;
211
+ }
212
+ function createSaparController(options) {
213
+ const { source, adapter, locales = [], sessionCap = true } = options;
214
+ const autoStartTimeoutMs = options.autoStartTimeoutMs ?? DEFAULT_AUTO_START_TIMEOUT_MS;
215
+ const onError = options.onError ?? ((error, context) => console.error(`[sapar] ${context}`, error));
216
+ const listeners = /* @__PURE__ */ new Set();
217
+ const autoStartedKeys = /* @__PURE__ */ new Set();
218
+ let route;
219
+ let inboxRequestId = 0;
220
+ let surfacedAnnouncementKey = null;
221
+ let destroyed = false;
222
+ const machine = createTourMachine(
223
+ adapter,
224
+ { onProgress: handleProgress, onError },
225
+ {
226
+ anchorTimeoutMs: options.anchorTimeoutMs ?? DEFAULT_ANCHOR_TIMEOUT_MS,
227
+ presentAnchorTimeoutMs: options.presentAnchorTimeoutMs ?? DEFAULT_PRESENT_ANCHOR_TIMEOUT_MS,
228
+ locales
229
+ }
230
+ );
231
+ let snapshot = { inbox: null, inboxStatus: "idle", runner: machine.getSnapshot() };
232
+ machine.subscribe(() => update({ runner: machine.getSnapshot() }));
233
+ function update(patch) {
234
+ const changed = Object.keys(patch).some((name) => patch[name] !== snapshot[name]);
235
+ if (!changed) return;
236
+ snapshot = { ...snapshot, ...patch };
237
+ listeners.forEach((listener) => listener());
238
+ }
239
+ function applySessionCap(interruption) {
240
+ if (!interruption || !sessionCap) return interruption;
241
+ const isSurfaced = interruption.kind === "announcement" && interruption.announcement.key === surfacedAnnouncementKey;
242
+ if (isSurfaced) return interruption;
243
+ if (hasSpentSessionInterruption(adapter)) return null;
244
+ if (interruption.kind === "announcement") {
245
+ surfacedAnnouncementKey = interruption.announcement.key;
246
+ spendSessionInterruption(adapter);
247
+ }
248
+ return interruption;
249
+ }
250
+ async function waitForPage(tour) {
251
+ const first = tour.steps.find((step) => step.anchor !== void 0);
252
+ if (first?.anchor === void 0 || requiresNavigation(first, adapter, locales)) return true;
253
+ return await adapter.resolveAnchor(first.anchor, { timeoutMs: autoStartTimeoutMs }) !== null;
254
+ }
255
+ async function autoStart(tour) {
256
+ if (snapshot.runner.phase !== "idle" || autoStartedKeys.has(tour.key)) return;
257
+ autoStartedKeys.add(tour.key);
258
+ const ready = await waitForPage(tour).catch((error) => {
259
+ onError(error, "autoStart");
260
+ return false;
261
+ });
262
+ if (!ready || destroyed || snapshot.runner.phase !== "idle") {
263
+ autoStartedKeys.delete(tour.key);
264
+ return;
265
+ }
266
+ await machine.start(tour, { autoStarted: true });
267
+ }
268
+ function handleProgress(input) {
269
+ if (input.status === "started" && input.autoStarted && sessionCap) spendSessionInterruption(adapter);
270
+ void reportProgress(input);
271
+ }
272
+ async function reportProgress(input) {
273
+ try {
274
+ await source.recordProgress(input);
275
+ } catch (error) {
276
+ onError(error, "progress");
277
+ return;
278
+ }
279
+ if (input.status !== "started") await refresh();
280
+ }
281
+ async function refresh() {
282
+ if (destroyed) return;
283
+ const requestId = ++inboxRequestId;
284
+ const query = { route: route === void 0 ? void 0 : normalizeRoute(route, locales), platform: adapter.platform };
285
+ if (!snapshot.inbox) update({ inboxStatus: "loading" });
286
+ try {
287
+ const inbox = await source.getInbox(query);
288
+ if (destroyed || requestId !== inboxRequestId) return;
289
+ const interruption = applySessionCap(inbox.interruption);
290
+ update({ inbox: { ...inbox, interruption }, inboxStatus: "ready" });
291
+ if (interruption?.kind === "tour") void autoStart(interruption.tour);
292
+ } catch (error) {
293
+ if (destroyed || requestId !== inboxRequestId) return;
294
+ update({ inboxStatus: "error" });
295
+ onError(error, "inbox");
296
+ }
297
+ }
298
+ async function loadTour(tourOrKey) {
299
+ if (typeof tourOrKey !== "string") return tourOrKey;
300
+ const tour = await source.getTour(tourOrKey);
301
+ if (!tour) onError(new Error(`No tour with key "${tourOrKey}".`), "startTour");
302
+ return tour;
303
+ }
304
+ async function startTour(tourOrKey) {
305
+ try {
306
+ const tour = await loadTour(tourOrKey);
307
+ if (tour && !destroyed) await machine.start(tour, { autoStarted: false });
308
+ } catch (error) {
309
+ onError(error, "startTour");
310
+ }
311
+ }
312
+ async function markSeen(keys) {
313
+ const flippedKeys = new Set(
314
+ snapshot.inbox?.items.filter((item) => item.unread && keys.includes(item.key)).map((item) => item.key)
315
+ );
316
+ if (snapshot.inbox) update({ inbox: withoutInterruption(withUnread(snapshot.inbox, flippedKeys, false), keys) });
317
+ try {
318
+ await source.markSeen(keys);
319
+ } catch (error) {
320
+ if (snapshot.inbox) update({ inbox: withUnread(snapshot.inbox, flippedKeys, true) });
321
+ onError(error, "markSeen");
322
+ }
323
+ }
324
+ async function dismiss(key) {
325
+ if (snapshot.inbox) update({ inbox: withoutInterruption(snapshot.inbox, [key]) });
326
+ try {
327
+ await source.dismiss(key);
328
+ } catch (error) {
329
+ onError(error, "dismiss");
330
+ return;
331
+ }
332
+ await refresh();
333
+ }
334
+ return {
335
+ subscribe(listener) {
336
+ listeners.add(listener);
337
+ return () => listeners.delete(listener);
338
+ },
339
+ getSnapshot: () => snapshot,
340
+ setRoute(nextRoute) {
341
+ route = nextRoute;
342
+ void refresh();
343
+ },
344
+ refresh,
345
+ startTour,
346
+ next: () => machine.next(),
347
+ previous: () => machine.previous(),
348
+ skip: () => machine.skip(),
349
+ markSeen,
350
+ dismiss,
351
+ recordClick(key) {
352
+ source.recordClick(key).catch((error) => onError(error, "recordClick"));
353
+ },
354
+ destroy() {
355
+ destroyed = true;
356
+ machine.destroy();
357
+ listeners.clear();
358
+ }
359
+ };
360
+ }
361
+
362
+ // src/core/define.ts
363
+ var KEY_PATTERN = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
364
+ function assertKey(kind, key) {
365
+ if (!KEY_PATTERN.test(key)) {
366
+ throw new Error(`Invalid ${kind} key "${key}": use lowercase letters, digits and underscores, dot-separated.`);
367
+ }
368
+ }
369
+ function assertUnique(kind, keys) {
370
+ const duplicate = keys.find((key, index) => keys.indexOf(key) !== index);
371
+ if (duplicate !== void 0) throw new Error(`Duplicate ${kind} key "${duplicate}".`);
372
+ }
373
+ function defineStep(input, tourKey) {
374
+ assertKey(`step in tour "${tourKey}"`, input.key);
375
+ if (input.anchor !== void 0) assertKey(`anchor of step "${input.key}"`, input.anchor);
376
+ const { placement = "auto", padding = 8, required = false, advanceOn = "next", ...rest } = input;
377
+ return { ...rest, placement, padding, required, advanceOn };
378
+ }
379
+ function defineTour(input) {
380
+ assertKey("tour", input.key);
381
+ const { revision = 1, trigger = "manual" } = input;
382
+ if (!Number.isInteger(revision) || revision < 1) {
383
+ throw new Error(`Tour "${input.key}" needs a positive integer revision, got ${revision}.`);
384
+ }
385
+ if (input.steps.length === 0) throw new Error(`Tour "${input.key}" needs at least one step.`);
386
+ if (trigger !== "manual" && !isValidRoutePattern(trigger.route)) {
387
+ throw new Error(`Tour "${input.key}" has an invalid route pattern "${trigger.route}".`);
388
+ }
389
+ const steps = input.steps.map((step) => defineStep(step, input.key));
390
+ assertUnique(`step in tour "${input.key}"`, steps.map((step) => step.key));
391
+ return { key: input.key, revision, title: input.title, trigger, steps, ...input.platforms && { platforms: input.platforms } };
392
+ }
393
+ function defineAnnouncement(input) {
394
+ if (Number.isNaN(Date.parse(input.publishedAt))) {
395
+ throw new Error(`Announcement "${input.key}" has an invalid publishedAt "${input.publishedAt}".`);
396
+ }
397
+ const { tier = "feed", ...rest } = input;
398
+ return { ...rest, tier };
399
+ }
400
+ function defineAnchors(keys) {
401
+ keys.forEach((key) => assertKey("anchor", key));
402
+ assertUnique("anchor", keys);
403
+ return keys;
404
+ }
405
+ function resolveText(text, locale, fallbackLocale) {
406
+ if (typeof text === "string") return text;
407
+ return text[locale] ?? text[fallbackLocale] ?? "";
408
+ }
409
+ function resolveStep(step, { locale, fallbackLocale }) {
410
+ return {
411
+ ...step,
412
+ title: resolveText(step.title, locale, fallbackLocale),
413
+ body: resolveText(step.body, locale, fallbackLocale)
414
+ };
415
+ }
416
+ function resolveTour(tour, textLocale) {
417
+ return {
418
+ ...tour,
419
+ title: resolveText(tour.title, textLocale.locale, textLocale.fallbackLocale),
420
+ steps: tour.steps.map((step) => resolveStep(step, textLocale))
421
+ };
422
+ }
423
+ function resolveAnnouncement(announcement, { locale, fallbackLocale }, unread) {
424
+ const { cta, ...rest } = announcement;
425
+ return {
426
+ ...rest,
427
+ title: resolveText(announcement.title, locale, fallbackLocale),
428
+ body: resolveText(announcement.body, locale, fallbackLocale),
429
+ ...cta && { cta: { label: resolveText(cta.label, locale, fallbackLocale), url: cta.url } },
430
+ unread
431
+ };
432
+ }
433
+
434
+ // src/core/policy.ts
435
+ var DEFAULT_POLICY = {
436
+ newUserGraceDays: 3,
437
+ cooldownHours: 24,
438
+ dismissalBackoffDays: 30
439
+ };
440
+ var HOUR_MS = 60 * 60 * 1e3;
441
+ var DAY_MS = 24 * HOUR_MS;
442
+ function isWithin(now, since, windowMs) {
443
+ return since !== null && now - since < windowMs;
444
+ }
445
+ function interruptionsAllowed({ policy, now, registeredAt, lastAutoInterruptionAt }) {
446
+ const inGrace = policy.newUserGraceDays > 0 && isWithin(now, registeredAt, policy.newUserGraceDays * DAY_MS);
447
+ const coolingDown = policy.cooldownHours > 0 && isWithin(now, lastAutoInterruptionAt, policy.cooldownHours * HOUR_MS);
448
+ return !inGrace && !coolingDown;
449
+ }
450
+ function isTourOfferable({ policy, now, state, revision }) {
451
+ if (!state || state.revision !== revision) return true;
452
+ if (state.status === "completed") return false;
453
+ if (state.status === "started") return true;
454
+ if (policy.dismissalBackoffDays <= 0) return true;
455
+ return state.dismissedAt !== null && !isWithin(now, state.dismissedAt, policy.dismissalBackoffDays * DAY_MS);
456
+ }
457
+ function pickTour(budget, { route, locales, tours, stateOf }) {
458
+ if (route === void 0) return void 0;
459
+ return tours.filter(
460
+ (tour) => typeof tour.trigger === "object" && routeMatches(tour.trigger.route, route, locales) && isTourOfferable({ ...budget, state: stateOf(tour), revision: tour.revision })
461
+ ).sort((a, b) => a.key < b.key ? -1 : 1)[0];
462
+ }
463
+ function pickInterruption(budget, candidates) {
464
+ if (!interruptionsAllowed(budget)) return null;
465
+ const tour = pickTour(budget, candidates);
466
+ if (tour) return { kind: "tour", tour };
467
+ const announcement = candidates.announcements.find(
468
+ (item) => item.tier === "modal" && item.unread && !candidates.dismissedKeys.has(item.key)
469
+ );
470
+ return announcement ? { kind: "announcement", announcement } : null;
471
+ }
472
+
473
+ // src/core/stores.ts
474
+ function memoryStore() {
475
+ const values = /* @__PURE__ */ new Map();
476
+ return {
477
+ get: (key) => values.get(key) ?? null,
478
+ set: (key, value) => {
479
+ values.set(key, value);
480
+ },
481
+ remove: (key) => {
482
+ values.delete(key);
483
+ }
484
+ };
485
+ }
486
+
487
+ // src/core/sources/memory.ts
488
+ var isRecordMap = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
489
+ var isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
490
+ function parseJson(raw) {
491
+ try {
492
+ return raw === null ? void 0 : JSON.parse(raw);
493
+ } catch {
494
+ return void 0;
495
+ }
496
+ }
497
+ function storedCell(store, key, isValid, empty) {
498
+ return {
499
+ async read() {
500
+ const value = parseJson(await store.get(key));
501
+ return isValid(value) ? value : empty();
502
+ },
503
+ async write(value) {
504
+ await store.set(key, JSON.stringify(value));
505
+ }
506
+ };
507
+ }
508
+ function translateTour(tour, translate) {
509
+ return {
510
+ ...tour,
511
+ title: translate(tour.title),
512
+ steps: tour.steps.map((step) => ({ ...step, title: translate(step.title), body: translate(step.body) }))
513
+ };
514
+ }
515
+ function translateAnnouncement(announcement, translate) {
516
+ const { cta } = announcement;
517
+ return {
518
+ ...announcement,
519
+ title: translate(announcement.title),
520
+ body: translate(announcement.body),
521
+ ...cta && { cta: { ...cta, label: translate(cta.label) } }
522
+ };
523
+ }
524
+ var recordId = (key, revision) => `${key}@${revision}`;
525
+ function toEpochMs(value) {
526
+ return value == null ? null : new Date(value).getTime();
527
+ }
528
+ function latestAutoStart(records) {
529
+ const starts = Object.values(records).filter((record) => record.autoStarted).map((record) => record.startedAt);
530
+ return starts.length > 0 ? Math.max(...starts) : null;
531
+ }
532
+ function memorySource(options) {
533
+ const { storage, namespace = "sapar", fallbackLocale = "en", translate = (text) => text } = options;
534
+ const policy = { ...DEFAULT_POLICY, ...options.policy };
535
+ const clock = options.now ?? Date.now;
536
+ const registeredAt = toEpochMs(options.registeredAt);
537
+ const announcementDefinitions = options.announcements ?? [];
538
+ const tourRecords = storedCell(storage, `${namespace}.tour-records`, isRecordMap, () => ({}));
539
+ const seenKeys = storedCell(storage, `${namespace}.seen`, isStringArray, () => []);
540
+ const dismissedKeys = storedCell(storage, `${namespace}.dismissed`, isStringArray, () => []);
541
+ let queue = Promise.resolve();
542
+ function serialized(task) {
543
+ const result = queue.then(task);
544
+ queue = result.catch(() => void 0);
545
+ return result;
546
+ }
547
+ function textLocale() {
548
+ const { locale = fallbackLocale } = options;
549
+ return { locale: typeof locale === "function" ? locale() : locale, fallbackLocale };
550
+ }
551
+ const readTour = (definition) => translateTour(resolveTour(definition, textLocale()), translate);
552
+ function findTour(key) {
553
+ const tour = options.tours.find((candidate) => candidate.key === key);
554
+ if (!tour) throw new Error(`No tour with key "${key}".`);
555
+ return tour;
556
+ }
557
+ const isKnownAnnouncement = (key) => announcementDefinitions.some((announcement) => announcement.key === key);
558
+ function requireAnnouncement(key) {
559
+ if (!isKnownAnnouncement(key)) throw new Error(`No announcement with key "${key}".`);
560
+ }
561
+ async function publishedAnnouncements(now) {
562
+ const seen = new Set(await seenKeys.read());
563
+ const locale = textLocale();
564
+ return announcementDefinitions.filter((announcement) => Date.parse(announcement.publishedAt) <= now).sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt) || (a.key < b.key ? -1 : 1)).map(
565
+ (announcement) => translateAnnouncement(resolveAnnouncement(announcement, locale, !seen.has(announcement.key)), translate)
566
+ );
567
+ }
568
+ async function addKeys(cell, keys) {
569
+ const merged = /* @__PURE__ */ new Set([...await cell.read(), ...keys]);
570
+ await cell.write([...merged]);
571
+ }
572
+ async function getInbox({ route, platform }) {
573
+ const now = clock();
574
+ const records = await tourRecords.read();
575
+ const items = await publishedAnnouncements(now);
576
+ const budget = { policy, now, registeredAt, lastAutoInterruptionAt: latestAutoStart(records) };
577
+ const interruption = pickInterruption(budget, {
578
+ route,
579
+ // The controller sends an already-normalized route.
580
+ locales: [],
581
+ tours: options.tours.filter((tour) => tour.platforms?.includes(platform) ?? true).map(readTour),
582
+ stateOf: (tour) => records[recordId(tour.key, tour.revision)],
583
+ announcements: items,
584
+ dismissedKeys: new Set(await dismissedKeys.read())
585
+ });
586
+ return {
587
+ unreadCount: items.filter((item) => item.unread && item.tier !== "silent").length,
588
+ items,
589
+ interruption
590
+ };
591
+ }
592
+ async function recordProgress({ key, revision, status, stepIndex, autoStarted }) {
593
+ findTour(key);
594
+ const records = await tourRecords.read();
595
+ const id = recordId(key, revision);
596
+ const existing = records[id];
597
+ const now = clock();
598
+ const finalStatus = existing?.status === "completed" ? "completed" : status;
599
+ records[id] = {
600
+ status: finalStatus,
601
+ revision,
602
+ lastStepIndex: Math.max(stepIndex, existing?.lastStepIndex ?? 0),
603
+ startedAt: existing?.startedAt ?? now,
604
+ dismissedAt: existing?.dismissedAt ?? (finalStatus === "dismissed" ? now : null),
605
+ autoStarted: autoStarted || (existing?.autoStarted ?? false)
606
+ };
607
+ await tourRecords.write(records);
608
+ }
609
+ async function markSeen(keys) {
610
+ await addKeys(seenKeys, keys.filter(isKnownAnnouncement));
611
+ }
612
+ return {
613
+ getInbox: (query) => serialized(() => getInbox(query)),
614
+ getTour: (key) => serialized(async () => {
615
+ const tour = options.tours.find((candidate) => candidate.key === key);
616
+ return tour ? readTour(tour) : null;
617
+ }),
618
+ recordProgress: (input) => serialized(() => recordProgress(input)),
619
+ markSeen: (keys) => serialized(() => markSeen(keys)),
620
+ dismiss: (key) => serialized(async () => {
621
+ requireAnnouncement(key);
622
+ await addKeys(seenKeys, [key]);
623
+ await addKeys(dismissedKeys, [key]);
624
+ }),
625
+ recordClick: (key) => serialized(async () => {
626
+ requireAnnouncement(key);
627
+ await addKeys(seenKeys, [key]);
628
+ }),
629
+ listAnnouncements: () => serialized(() => publishedAnnouncements(clock()))
630
+ };
631
+ }
632
+
633
+ // src/core/wire.ts
634
+ function fromWireStep(step) {
635
+ return {
636
+ key: step.step_key,
637
+ ...step.anchor_key && { anchor: step.anchor_key },
638
+ ...step.route && { route: step.route },
639
+ title: step.title,
640
+ body: step.body,
641
+ placement: step.placement,
642
+ padding: step.spotlight_padding,
643
+ required: step.is_required,
644
+ advanceOn: "next"
645
+ };
646
+ }
647
+ function fromWireTour(tour) {
648
+ return {
649
+ key: tour.key,
650
+ revision: tour.revision,
651
+ title: tour.title,
652
+ trigger: tour.trigger === "route" ? { route: "*" } : "manual",
653
+ steps: [...tour.steps].sort((a, b) => a.order - b.order).map(fromWireStep)
654
+ };
655
+ }
656
+ function fromWireAnnouncement(announcement) {
657
+ return {
658
+ key: announcement.key,
659
+ tier: announcement.tier,
660
+ title: announcement.title,
661
+ body: announcement.body,
662
+ ...announcement.image_url && { imageUrl: announcement.image_url },
663
+ ...announcement.cta_url && { cta: { label: announcement.cta_label, url: announcement.cta_url } },
664
+ publishedAt: announcement.published_at,
665
+ ...announcement.tour_key && { tourKey: announcement.tour_key },
666
+ unread: announcement.is_unread
667
+ };
668
+ }
669
+ function fromWireInterruption({ kind, tour, announcement }) {
670
+ if (kind === "tour" && tour) return { kind, tour: fromWireTour(tour) };
671
+ if (kind === "announcement" && announcement) return { kind, announcement: fromWireAnnouncement(announcement) };
672
+ return null;
673
+ }
674
+ function fromWireInbox(inbox) {
675
+ return {
676
+ unreadCount: inbox.unread_count,
677
+ items: inbox.items.map(fromWireAnnouncement),
678
+ interruption: inbox.interruption && fromWireInterruption(inbox.interruption)
679
+ };
680
+ }
681
+ function toWireProgress({ status, stepIndex, autoStarted }) {
682
+ return { status, step_index: stepIndex, auto_started: autoStarted };
683
+ }
684
+
685
+ // src/core/sources/http.ts
686
+ var SaparHttpError = class extends Error {
687
+ constructor(status, message) {
688
+ super(message);
689
+ this.status = status;
690
+ this.name = "SaparHttpError";
691
+ }
692
+ status;
693
+ };
694
+ function resolveValue(value) {
695
+ return typeof value === "function" ? value() : value;
696
+ }
697
+ function buildUrl(baseUrl, path, query) {
698
+ const params = new URLSearchParams();
699
+ Object.entries(query).forEach(([name, value]) => value !== void 0 && params.set(name, value));
700
+ const search = params.toString();
701
+ return `${baseUrl.replace(/\/+$/, "")}/${path}${search && `?${search}`}`;
702
+ }
703
+ function httpSource(options) {
704
+ async function send(method, path, query = {}, body) {
705
+ const headers = new Headers(await resolveValue(options.headers ?? {}));
706
+ headers.set("Accept", "application/json");
707
+ if (body !== void 0) headers.set("Content-Type", "application/json");
708
+ const response = await (options.fetch ?? fetch)(buildUrl(options.baseUrl, path, query), {
709
+ method,
710
+ headers,
711
+ credentials: options.credentials,
712
+ body: body === void 0 ? void 0 : JSON.stringify(body)
713
+ });
714
+ if (!response.ok) throw new SaparHttpError(response.status, `${method} ${path} failed with ${response.status}`);
715
+ return response;
716
+ }
717
+ async function getJson(path, query = {}) {
718
+ const lang = options.locale === void 0 ? void 0 : resolveValue(options.locale);
719
+ const response = await send("GET", path, { lang, ...query });
720
+ return await response.json();
721
+ }
722
+ async function post(path, body) {
723
+ await send("POST", path, {}, body);
724
+ }
725
+ const tourPath = (key) => `tours/${encodeURIComponent(key)}/`;
726
+ return {
727
+ async getInbox({ route, platform }) {
728
+ return fromWireInbox(await getJson("inbox/", { route, platform }));
729
+ },
730
+ async getTour(key) {
731
+ try {
732
+ return fromWireTour(await getJson(tourPath(key)));
733
+ } catch (error) {
734
+ if (error instanceof SaparHttpError && error.status === 404) return null;
735
+ throw error;
736
+ }
737
+ },
738
+ recordProgress: (input) => post(`${tourPath(input.key)}progress/`, toWireProgress(input)),
739
+ markSeen: (keys) => post("seen/", { keys }),
740
+ dismiss: (key) => post("dismiss/", { key }),
741
+ recordClick: (key) => post(`announcements/${encodeURIComponent(key)}/click/`),
742
+ async listAnnouncements() {
743
+ return (await getJson("announcements/")).map(fromWireAnnouncement);
744
+ }
745
+ };
746
+ }
747
+
748
+ // src/core/testing.ts
749
+ var RECT = { x: 0, y: 0, width: 100, height: 40 };
750
+ function createAnchorHandle(key) {
751
+ const listeners = /* @__PURE__ */ new Set();
752
+ return {
753
+ key,
754
+ measure: async () => RECT,
755
+ onPress(listener) {
756
+ listeners.add(listener);
757
+ return () => listeners.delete(listener);
758
+ },
759
+ press: () => [...listeners].forEach((listener) => listener()),
760
+ pressListenerCount: () => listeners.size
761
+ };
762
+ }
763
+ function createTestAdapter(options = {}) {
764
+ const anchors = /* @__PURE__ */ new Map();
765
+ const waiting = /* @__PURE__ */ new Map();
766
+ const delayed = new Set(options.delayedAnchors);
767
+ const sessionValues = /* @__PURE__ */ new Map();
768
+ let route = options.route ?? "/";
769
+ let now = options.now ?? 0;
770
+ function showAnchor(key) {
771
+ const handle = anchors.get(key) ?? createAnchorHandle(key);
772
+ anchors.set(key, handle);
773
+ waiting.get(key)?.forEach((resolve) => resolve(handle));
774
+ waiting.delete(key);
775
+ return handle;
776
+ }
777
+ function waitForAnchor(key, timeoutMs) {
778
+ return new Promise((resolve) => {
779
+ const timer = setTimeout(() => resolve(null), timeoutMs);
780
+ waiting.set(key, [
781
+ ...waiting.get(key) ?? [],
782
+ (handle) => {
783
+ clearTimeout(timer);
784
+ resolve(handle);
785
+ }
786
+ ]);
787
+ });
788
+ }
789
+ options.anchors?.forEach(showAnchor);
790
+ return {
791
+ platform: options.platform ?? "web",
792
+ anchors,
793
+ navigations: [],
794
+ scrolls: [],
795
+ resolveRequests: [],
796
+ sessionValues,
797
+ showAnchor,
798
+ hideAnchor: (key) => void anchors.delete(key),
799
+ setRoute: (next) => {
800
+ route = next;
801
+ },
802
+ advanceTime: (ms) => {
803
+ now += ms;
804
+ },
805
+ async resolveAnchor(key, { timeoutMs }) {
806
+ this.resolveRequests.push({ key, timeoutMs });
807
+ const present = anchors.get(key);
808
+ if (present) return present;
809
+ return delayed.has(key) ? waitForAnchor(key, timeoutMs) : null;
810
+ },
811
+ async scrollIntoView(handle, { reducedMotion }) {
812
+ this.scrolls.push({ handle, reducedMotion });
813
+ },
814
+ currentRoute: () => route,
815
+ async navigate(destination) {
816
+ this.navigations.push(destination);
817
+ route = destination;
818
+ },
819
+ prefersReducedMotion: () => options.reducedMotion ?? false,
820
+ isCompact: () => options.compact ?? false,
821
+ session: {
822
+ get: (key) => sessionValues.get(key) ?? null,
823
+ set: (key, value) => void sessionValues.set(key, value)
824
+ },
825
+ now: () => now
826
+ };
827
+ }
828
+
829
+ // src/core/spotlightPath.ts
830
+ function roundedRectPath(x, y, width, height, requestedRadius) {
831
+ const r = Math.max(0, Math.min(requestedRadius, width / 2, height / 2));
832
+ return [
833
+ `M${x + r},${y}`,
834
+ `H${x + width - r}`,
835
+ `A${r},${r} 0 0 1 ${x + width},${y + r}`,
836
+ `V${y + height - r}`,
837
+ `A${r},${r} 0 0 1 ${x + width - r},${y + height}`,
838
+ `H${x + r}`,
839
+ `A${r},${r} 0 0 1 ${x},${y + height - r}`,
840
+ `V${y + r}`,
841
+ `A${r},${r} 0 0 1 ${x + r},${y}`,
842
+ "Z"
843
+ ].join(" ");
844
+ }
845
+ function spotlightPath({ rect, padding, radius, bounds }) {
846
+ const outer = `M0,0 H${bounds.width} V${bounds.height} H0 Z`;
847
+ if (!rect) return outer;
848
+ const cutout = roundedRectPath(rect.x - padding, rect.y - padding, rect.width + padding * 2, rect.height + padding * 2, radius);
849
+ return `${outer} ${cutout}`;
850
+ }
851
+
852
+ // src/core/presentation.ts
853
+ function resolvePresentation({ presentation, compact, fits }) {
854
+ if (presentation !== "auto") return presentation;
855
+ return compact || fits === false ? "sheet" : "anchored";
856
+ }
857
+
858
+ export {
859
+ SESSION_CAP_KEY,
860
+ hasSpentSessionInterruption,
861
+ spendSessionInterruption,
862
+ normalizeRoute,
863
+ routeMatches,
864
+ isValidRoutePattern,
865
+ DEFAULT_ANCHOR_TIMEOUT_MS,
866
+ DEFAULT_PRESENT_ANCHOR_TIMEOUT_MS,
867
+ DEFAULT_AUTO_START_TIMEOUT_MS,
868
+ createTourMachine,
869
+ createSaparController,
870
+ KEY_PATTERN,
871
+ defineTour,
872
+ defineAnnouncement,
873
+ defineAnchors,
874
+ resolveText,
875
+ resolveTour,
876
+ resolveAnnouncement,
877
+ DEFAULT_POLICY,
878
+ interruptionsAllowed,
879
+ isTourOfferable,
880
+ pickInterruption,
881
+ memoryStore,
882
+ memorySource,
883
+ fromWireTour,
884
+ fromWireAnnouncement,
885
+ fromWireInbox,
886
+ toWireProgress,
887
+ SaparHttpError,
888
+ httpSource,
889
+ createTestAdapter,
890
+ spotlightPath,
891
+ resolvePresentation
892
+ };
893
+ //# sourceMappingURL=chunk-FM64UDKJ.js.map