@guidelytech/react-native 0.0.0-stage → 0.4.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/dist/index.js ADDED
@@ -0,0 +1,4451 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ // src/index.tsx
6
+ import { useContext as useContext2, useEffect as useEffect7, useRef as useRef5, useState as useState6 } from "react";
7
+ import { AppState, Dimensions, Linking, Platform as Platform6 } from "react-native";
8
+
9
+ // ../shared/dist/chunk-QUKMOTT4.js
10
+ function fnv1a(input) {
11
+ let h = 2166136261;
12
+ for (let i = 0; i < input.length; i++) {
13
+ h ^= input.charCodeAt(i);
14
+ h = Math.imul(h, 16777619) >>> 0;
15
+ }
16
+ return h >>> 0;
17
+ }
18
+ function rolloutBucket(subject, salt) {
19
+ return fnv1a(`${salt}:${subject}`) % 100;
20
+ }
21
+ var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
22
+ function randomId(size = 16) {
23
+ const g = typeof globalThis !== "undefined" ? globalThis : {};
24
+ let bytes;
25
+ if (g.crypto && typeof g.crypto.getRandomValues === "function") {
26
+ bytes = new Uint8Array(size);
27
+ g.crypto.getRandomValues(bytes);
28
+ } else {
29
+ bytes = new Uint8Array(size);
30
+ for (let i = 0; i < size; i++) bytes[i] = Math.floor(Math.random() * 256);
31
+ }
32
+ let out = "";
33
+ for (let i = 0; i < size; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
34
+ return out;
35
+ }
36
+ function getPath(obj, path) {
37
+ if (!path) return obj;
38
+ let cur = obj;
39
+ for (const part of path.split(".")) {
40
+ if (cur == null) return void 0;
41
+ cur = cur[part];
42
+ }
43
+ return cur;
44
+ }
45
+ function matchUrlPattern(pattern, url) {
46
+ if (!pattern) return true;
47
+ if (pattern === url) return true;
48
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__GLOBSTAR__").replace(/\*/g, "[^/]*").replace(/__GLOBSTAR__/g, ".*");
49
+ const re = new RegExp(`^${escaped}$`);
50
+ return re.test(url) || re.test(stripOrigin(url));
51
+ }
52
+ function stripOrigin(url) {
53
+ try {
54
+ const u = new URL(url);
55
+ return u.pathname + u.search + u.hash;
56
+ } catch {
57
+ return url;
58
+ }
59
+ }
60
+ function safeRegExp(source, flags = "i") {
61
+ if (!source || source.length > 200) return null;
62
+ try {
63
+ return new RegExp(source, flags);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ var DEFAULT_PLATFORMS = ["web"];
69
+ function guidePlatforms(config) {
70
+ return Array.isArray(config.platforms) ? [...config.platforms] : [...DEFAULT_PLATFORMS];
71
+ }
72
+ function supportsPlatform(config, platform) {
73
+ return guidePlatforms(config).includes(platform);
74
+ }
75
+ function guidelyIdFromSelector(value) {
76
+ const v = (value ?? "").trim();
77
+ if (!v) return null;
78
+ if (/^[A-Za-z0-9_][A-Za-z0-9_\-:.]*$/.test(v)) return v;
79
+ const hash = v.match(/^#([A-Za-z0-9_][A-Za-z0-9_\-:.]*)$/);
80
+ if (hash) return hash[1];
81
+ const attr2 = v.match(/^(?:[a-z][a-z0-9]*)?\[\s*data-(?:guidely-id|testid|tour)\s*=\s*(["']?)([^"'\]]+)\1\s*\]$/i);
82
+ if (attr2) return attr2[2].trim();
83
+ return null;
84
+ }
85
+ function screenPath(screen) {
86
+ const s = (screen ?? "").trim();
87
+ if (!s) return "/";
88
+ return s.startsWith("/") ? s : `/${s}`;
89
+ }
90
+ function compareVersions(a, b) {
91
+ const sa = a == null ? "" : String(a).trim().replace(/^v/i, "");
92
+ const sb = b == null ? "" : String(b).trim().replace(/^v/i, "");
93
+ if (!sa || !sb) return null;
94
+ const pa = sa.split(/[.+-]/);
95
+ const pb = sb.split(/[.+-]/);
96
+ const n = Math.max(pa.length, pb.length);
97
+ for (let i = 0; i < n; i++) {
98
+ const x = pa[i] ?? "0";
99
+ const y = pb[i] ?? "0";
100
+ const nx = /^\d+$/.test(x) ? Number(x) : NaN;
101
+ const ny = /^\d+$/.test(y) ? Number(y) : NaN;
102
+ if (!Number.isNaN(nx) && !Number.isNaN(ny)) {
103
+ if (nx !== ny) return nx < ny ? -1 : 1;
104
+ } else if (x !== y) {
105
+ return x < y ? -1 : 1;
106
+ }
107
+ }
108
+ return 0;
109
+ }
110
+ function isQuestionVisible(q, answers) {
111
+ if (!q.showWhen) return true;
112
+ const actual = answers[q.showWhen.questionId];
113
+ if (actual === void 0) return false;
114
+ const expected = q.showWhen.value;
115
+ switch (q.showWhen.op) {
116
+ case "equals":
117
+ return Array.isArray(actual) ? actual.includes(String(expected)) : String(actual) === String(expected);
118
+ case "lte":
119
+ return Number(actual) <= Number(expected);
120
+ case "gte":
121
+ return Number(actual) >= Number(expected);
122
+ case "in": {
123
+ const list = Array.isArray(expected) ? expected : [String(expected)];
124
+ return Array.isArray(actual) ? actual.some((a) => list.includes(a)) : list.includes(String(actual));
125
+ }
126
+ default:
127
+ return false;
128
+ }
129
+ }
130
+ function isChecklistComplete(config, progress) {
131
+ const done = new Set(progress.completedTaskIds);
132
+ return config.tasks.filter((t) => t.required).every((t) => done.has(t.id));
133
+ }
134
+ function isRuleGroup(r) {
135
+ return r.rules !== void 0;
136
+ }
137
+ function has(set, v) {
138
+ if (!set) return false;
139
+ return set instanceof Set ? set.has(v) : set.includes(v);
140
+ }
141
+ function resolveField(rule, ctx) {
142
+ switch (rule.field) {
143
+ case "user.attribute":
144
+ return rule.key ? getPath(ctx.userAttributes, rule.key) : void 0;
145
+ case "account.attribute":
146
+ return rule.key ? getPath(ctx.accountAttributes, rule.key) : void 0;
147
+ case "user.role":
148
+ return ctx.userAttributes.role;
149
+ case "user.plan":
150
+ return ctx.userAttributes.plan ?? ctx.accountAttributes.plan;
151
+ case "url.path":
152
+ return ctx.path;
153
+ case "url.full":
154
+ return ctx.url;
155
+ case "url.query":
156
+ return rule.key ? ctx.query[rule.key] : void 0;
157
+ case "device.type":
158
+ return ctx.device.type;
159
+ case "device.browser":
160
+ return ctx.device.browser;
161
+ case "device.os":
162
+ return ctx.device.os;
163
+ case "visitor.firstVisit":
164
+ return ctx.firstVisit;
165
+ case "visitor.returning":
166
+ return !ctx.firstVisit;
167
+ case "event.seen":
168
+ return rule.key ? has(ctx.eventsSeen, rule.key) : false;
169
+ case "guide.completed":
170
+ return rule.key ? has(ctx.completedGuides, rule.key) : false;
171
+ case "guide.dismissed":
172
+ return rule.key ? has(ctx.dismissedGuides, rule.key) : false;
173
+ case "guide.seen":
174
+ return rule.key ? has(ctx.seenGuides, rule.key) : false;
175
+ case "checklist.completed":
176
+ return rule.key ? has(ctx.completedChecklists, rule.key) : false;
177
+ case "audience.member":
178
+ return rule.key ? has(ctx.audienceIds, rule.key) : false;
179
+ case "datetime.now":
180
+ return ctx.now.toISOString();
181
+ case "datetime.dayOfWeek":
182
+ return ctx.now.getDay();
183
+ case "datetime.hour":
184
+ return ctx.now.getHours();
185
+ case "rollout.percentage":
186
+ return rolloutBucket(ctx.userId || ctx.anonymousId, ctx.rolloutSalt || "guidely");
187
+ case "environment":
188
+ return ctx.environment;
189
+ case "locale":
190
+ return ctx.locale;
191
+ case "app.platform":
192
+ return ctx.platform ?? "web";
193
+ case "app.screen":
194
+ return ctx.screen;
195
+ case "app.version":
196
+ return ctx.appVersion;
197
+ default:
198
+ return void 0;
199
+ }
200
+ }
201
+ function toNumber(v) {
202
+ if (typeof v === "number") return v;
203
+ if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v);
204
+ if (v instanceof Date) return v.getTime();
205
+ return null;
206
+ }
207
+ function toDateMs(v) {
208
+ if (v instanceof Date) return v.getTime();
209
+ if (typeof v === "string") {
210
+ const t = Date.parse(v);
211
+ return Number.isNaN(t) ? null : t;
212
+ }
213
+ if (typeof v === "number") return v;
214
+ return null;
215
+ }
216
+ function compare(op, actual, expected) {
217
+ switch (op) {
218
+ case "exists":
219
+ return actual !== void 0 && actual !== null && actual !== "";
220
+ case "not_exists":
221
+ return actual === void 0 || actual === null || actual === "";
222
+ case "is_true":
223
+ return actual === true || actual === "true";
224
+ case "is_false":
225
+ return actual === false || actual === "false";
226
+ case "equals":
227
+ if (typeof actual === "boolean") return String(actual) === String(expected);
228
+ return String(actual ?? "") === String(expected ?? "");
229
+ case "not_equals":
230
+ return String(actual ?? "") !== String(expected ?? "");
231
+ case "contains":
232
+ return typeof actual === "string" && actual.toLowerCase().includes(String(expected ?? "").toLowerCase());
233
+ case "not_contains":
234
+ return !(typeof actual === "string" && actual.toLowerCase().includes(String(expected ?? "").toLowerCase()));
235
+ case "starts_with":
236
+ return typeof actual === "string" && actual.startsWith(String(expected ?? ""));
237
+ case "ends_with":
238
+ return typeof actual === "string" && actual.endsWith(String(expected ?? ""));
239
+ case "gt":
240
+ case "gte":
241
+ case "lt":
242
+ case "lte": {
243
+ const a = toNumber(actual);
244
+ const b = toNumber(expected);
245
+ if (a === null || b === null) return false;
246
+ return op === "gt" ? a > b : op === "gte" ? a >= b : op === "lt" ? a < b : a <= b;
247
+ }
248
+ case "in": {
249
+ const list = Array.isArray(expected) ? expected : String(expected ?? "").split(",").map((s) => s.trim());
250
+ return list.includes(String(actual ?? ""));
251
+ }
252
+ case "not_in": {
253
+ const list = Array.isArray(expected) ? expected : String(expected ?? "").split(",").map((s) => s.trim());
254
+ return !list.includes(String(actual ?? ""));
255
+ }
256
+ case "regex": {
257
+ const re = safeRegExp(String(expected ?? ""));
258
+ return !!re && typeof actual === "string" && re.test(actual);
259
+ }
260
+ case "before": {
261
+ const a = toDateMs(actual);
262
+ const b = toDateMs(expected);
263
+ return a !== null && b !== null && a < b;
264
+ }
265
+ case "after": {
266
+ const a = toDateMs(actual);
267
+ const b = toDateMs(expected);
268
+ return a !== null && b !== null && a > b;
269
+ }
270
+ default:
271
+ return false;
272
+ }
273
+ }
274
+ function evaluateRule(rule, ctx) {
275
+ let result;
276
+ if (rule.field === "url.path" || rule.field === "url.full" || rule.field === "app.screen") {
277
+ const actual = String(resolveField(rule, ctx) ?? "");
278
+ if (rule.op === "equals") result = matchUrlPattern(String(rule.value ?? ""), actual);
279
+ else if (rule.op === "not_equals") result = !matchUrlPattern(String(rule.value ?? ""), actual);
280
+ else result = compare(rule.op, actual, rule.value);
281
+ } else if (rule.field === "app.version" && ["equals", "not_equals", "gt", "gte", "lt", "lte"].includes(rule.op)) {
282
+ const c = compareVersions(resolveField(rule, ctx), rule.value);
283
+ if (c === null) result = rule.op === "not_equals";
284
+ else result = rule.op === "equals" ? c === 0 : rule.op === "not_equals" ? c !== 0 : rule.op === "gt" ? c > 0 : rule.op === "gte" ? c >= 0 : rule.op === "lt" ? c < 0 : c <= 0;
285
+ } else {
286
+ result = compare(rule.op, resolveField(rule, ctx), rule.value);
287
+ }
288
+ return rule.negate ? !result : result;
289
+ }
290
+ function evaluateRules(group, ctx) {
291
+ if (!group || group.rules.length === 0) return group?.negate ? false : true;
292
+ const results = group.rules.map((r) => isRuleGroup(r) ? evaluateRules(r, ctx) : evaluateRule(r, ctx));
293
+ const value = group.op === "and" ? results.every(Boolean) : results.some(Boolean);
294
+ return group.negate ? !value : value;
295
+ }
296
+ var SENSITIVE_PROPERTY_PATTERN = /(password|passwd|secret|token|credit|card|cvv|ssn|iban)/i;
297
+ var MAX_PROPERTY_BYTES = 8 * 1024;
298
+ function sanitizeProperties(props) {
299
+ if (!props) return {};
300
+ const out = {};
301
+ let size = 0;
302
+ for (const [k, v] of Object.entries(props)) {
303
+ if (SENSITIVE_PROPERTY_PATTERN.test(k)) continue;
304
+ if (typeof v === "function" || typeof v === "symbol") continue;
305
+ const str = typeof v === "string" ? v : JSON.stringify(v ?? null);
306
+ size += k.length + (str?.length ?? 0);
307
+ if (size > MAX_PROPERTY_BYTES) break;
308
+ out[k.slice(0, 100)] = typeof v === "string" ? v.slice(0, 1e3) : v;
309
+ }
310
+ return out;
311
+ }
312
+ var SOURCEMAP_MAX_FILE_BYTES = 25 * 1024 * 1024;
313
+
314
+ // ../sdk-core/dist/chunk-GJT7CFAC.js
315
+ var __defProp2 = Object.defineProperty;
316
+ var __defNormalProp2 = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
317
+ var __publicField2 = (obj, key, value) => __defNormalProp2(obj, typeof key !== "symbol" ? key + "" : key, value);
318
+ var Api = class {
319
+ constructor(baseUrl, key, fetchImpl) {
320
+ __publicField2(this, "baseUrl", baseUrl);
321
+ __publicField2(this, "key", key);
322
+ __publicField2(this, "fetchImpl", fetchImpl);
323
+ }
324
+ url(path, params = {}) {
325
+ const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
326
+ return `${this.baseUrl.replace(/\/$/, "")}/api/v1/sdk${path}?${query}`;
327
+ }
328
+ get fetcher() {
329
+ if (this.fetchImpl) return this.fetchImpl;
330
+ return typeof fetch === "function" ? fetch.bind(globalThis) : null;
331
+ }
332
+ async request(method, path, options = {}) {
333
+ const f = this.fetcher;
334
+ if (!f) throw new Error("fetch is not available");
335
+ const headers = { ...options.headers, "X-Guidely-Key": this.key, Accept: "application/json" };
336
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
337
+ if (options.etag) headers["If-None-Match"] = options.etag;
338
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
339
+ const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
340
+ try {
341
+ const res = await f(this.url(path, options.params), {
342
+ method,
343
+ headers,
344
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
345
+ keepalive: options.keepalive,
346
+ signal: ac?.signal,
347
+ credentials: "omit",
348
+ mode: "cors"
349
+ });
350
+ let data = null;
351
+ if (res.status !== 304 && res.status !== 204) {
352
+ try {
353
+ data = await res.json();
354
+ } catch {
355
+ data = null;
356
+ }
357
+ }
358
+ return { status: res.status, ok: res.ok, data, etag: res.headers?.get?.("etag") ?? null };
359
+ } finally {
360
+ if (timer) clearTimeout(timer);
361
+ }
362
+ }
363
+ get(path, options) {
364
+ return this.request("GET", path, options);
365
+ }
366
+ post(path, body, options) {
367
+ return this.request("POST", path, { body, ...options });
368
+ }
369
+ /**
370
+ * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
371
+ * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.
372
+ */
373
+ async stream(path, body, onEvent, headers = {}) {
374
+ const f = this.fetcher;
375
+ if (!f) throw new Error("fetch is not available");
376
+ const res = await f(this.url(path), {
377
+ method: "POST",
378
+ headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
379
+ body: JSON.stringify(body),
380
+ credentials: "omit",
381
+ mode: "cors"
382
+ });
383
+ const type = res.headers?.get?.("content-type") ?? "";
384
+ if (!type.includes("text/event-stream") || !res.body) {
385
+ let data = null;
386
+ try {
387
+ data = await res.json();
388
+ } catch {
389
+ data = null;
390
+ }
391
+ return { status: res.status, ok: res.ok, data, etag: null };
392
+ }
393
+ await readEventStream(res.body, onEvent);
394
+ return { status: res.status, ok: res.ok, data: null, etag: null };
395
+ }
396
+ /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
397
+ beacon(path, body) {
398
+ try {
399
+ if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") return false;
400
+ const blob = new Blob([JSON.stringify(body)], { type: "text/plain;charset=UTF-8" });
401
+ return navigator.sendBeacon(this.url(path), blob);
402
+ } catch {
403
+ return false;
404
+ }
405
+ }
406
+ };
407
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
408
+ function backoffMs(attempt, baseMs = 1e3, maxMs = 3e4) {
409
+ return Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt));
410
+ }
411
+ async function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
412
+ for (let i = 0; i < attempts; i++) {
413
+ try {
414
+ const res = await api.post(path, body);
415
+ if (res.ok || res.status >= 400 && res.status < 500 && res.status !== 429) return res;
416
+ } catch {
417
+ }
418
+ if (i < attempts - 1) await sleep(backoffMs(i, baseMs, 8e3));
419
+ }
420
+ return null;
421
+ }
422
+ async function readEventStream(body, onEvent) {
423
+ const reader = body.getReader();
424
+ const decoder = new TextDecoder();
425
+ let buffer = "";
426
+ const dispatch = (raw) => {
427
+ let event = "message";
428
+ const data = [];
429
+ for (const line of raw.split("\n")) {
430
+ if (!line || line.startsWith(":")) continue;
431
+ const i = line.indexOf(":");
432
+ const field = i < 0 ? line : line.slice(0, i);
433
+ const value = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
434
+ if (field === "event") event = value;
435
+ else if (field === "data") data.push(value);
436
+ }
437
+ if (!data.length) return;
438
+ try {
439
+ onEvent(event, JSON.parse(data.join("\n")));
440
+ } catch {
441
+ }
442
+ };
443
+ for (; ; ) {
444
+ const { done, value } = await reader.read();
445
+ if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
446
+ let sep;
447
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
448
+ dispatch(buffer.slice(0, sep));
449
+ buffer = buffer.slice(sep + 2);
450
+ }
451
+ if (done) break;
452
+ }
453
+ if (buffer.trim()) dispatch(buffer);
454
+ }
455
+
456
+ // ../sdk-core/dist/chunk-HLR3W63Z.js
457
+ function readJson(store, key, fallback) {
458
+ const raw = store.get(key);
459
+ if (raw == null) return fallback;
460
+ try {
461
+ return JSON.parse(raw);
462
+ } catch {
463
+ return fallback;
464
+ }
465
+ }
466
+ function writeJson(store, key, value) {
467
+ try {
468
+ store.set(key, JSON.stringify(value));
469
+ } catch {
470
+ }
471
+ }
472
+ var KEYS = {
473
+ anon: "guidely:anon",
474
+ seen: "guidely:seen",
475
+ config: (key) => `guidely:config:${key}`,
476
+ etag: (key) => `guidely:etag:${key}`,
477
+ state: (key) => `guidely:state:${key}`,
478
+ user: (key) => `guidely:user:${key}`,
479
+ queue: (key) => `guidely:queue:${key}`,
480
+ session: "guidely:session",
481
+ active: "guidely:active",
482
+ authoring: "guidely:authoring",
483
+ /** Messenger conversations this browser can read: [{ id, token }]. */
484
+ conversations: (key) => `guidely:conversations:${key}`
485
+ };
486
+ var EventQueue = class {
487
+ constructor(opts) {
488
+ __publicField2(this, "opts", opts);
489
+ __publicField2(this, "events", []);
490
+ __publicField2(this, "timer", null);
491
+ __publicField2(this, "retryTimer", null);
492
+ __publicField2(this, "inflight", false);
493
+ __publicField2(this, "inflightIds", /* @__PURE__ */ new Set());
494
+ __publicField2(this, "attempt", 0);
495
+ __publicField2(this, "destroyed", false);
496
+ __publicField2(this, "flushIntervalMs");
497
+ __publicField2(this, "maxQueue");
498
+ __publicField2(this, "batchSize");
499
+ __publicField2(this, "maxAttempts");
500
+ this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
501
+ this.maxQueue = opts.maxQueue ?? 200;
502
+ this.batchSize = opts.batchSize ?? 50;
503
+ this.maxAttempts = opts.maxAttempts ?? 5;
504
+ const persisted = readJson(opts.store, opts.storageKey, []);
505
+ if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
506
+ if (this.events.length) this.schedule();
507
+ }
508
+ get size() {
509
+ return this.events.length;
510
+ }
511
+ /** Pending events (read-only copy, for debugging). */
512
+ peek() {
513
+ return this.events.slice();
514
+ }
515
+ enqueue(event) {
516
+ if (this.destroyed) return;
517
+ if (this.events.some((e) => e.eventId === event.eventId)) return;
518
+ this.events.push(event);
519
+ if (this.events.length > this.maxQueue) this.events.splice(0, this.events.length - this.maxQueue);
520
+ this.persist();
521
+ this.schedule();
522
+ }
523
+ persist() {
524
+ writeJson(this.opts.store, this.opts.storageKey, this.events);
525
+ }
526
+ schedule() {
527
+ if (this.timer || this.retryTimer || this.destroyed) return;
528
+ this.timer = setTimeout(() => {
529
+ this.timer = null;
530
+ void this.flush();
531
+ }, this.flushIntervalMs);
532
+ }
533
+ /** Sends queued events now (one batch at a time). Safe to call at any time. */
534
+ async flush() {
535
+ if (this.destroyed || this.inflight || !this.events.length || !this.opts.enabled()) return;
536
+ if (this.timer) {
537
+ clearTimeout(this.timer);
538
+ this.timer = null;
539
+ }
540
+ this.inflight = true;
541
+ const batch = this.events.slice(0, this.batchSize);
542
+ this.inflightIds = new Set(batch.map((e) => e.eventId));
543
+ let ok = false;
544
+ let retry = true;
545
+ try {
546
+ const res = await this.opts.send(batch);
547
+ ok = res.ok;
548
+ retry = res.retry ?? !res.ok;
549
+ } catch (err) {
550
+ this.opts.onError?.(err);
551
+ }
552
+ this.inflight = false;
553
+ this.inflightIds.clear();
554
+ if (this.destroyed) return;
555
+ if (ok || !retry) {
556
+ const ids = new Set(batch.map((e) => e.eventId));
557
+ this.events = this.events.filter((e) => !ids.has(e.eventId));
558
+ this.attempt = 0;
559
+ this.persist();
560
+ if (this.events.length) this.schedule();
561
+ return;
562
+ }
563
+ this.attempt += 1;
564
+ if (this.attempt >= this.maxAttempts) {
565
+ this.attempt = 0;
566
+ return;
567
+ }
568
+ this.retryTimer = setTimeout(() => {
569
+ this.retryTimer = null;
570
+ void this.flush();
571
+ }, backoffMs(this.attempt - 1));
572
+ }
573
+ /** Best-effort synchronous flush on unload using `sendBeacon`. */
574
+ flushSync() {
575
+ if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
576
+ const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
577
+ if (!batch.length) return;
578
+ if (this.opts.beacon(batch)) {
579
+ const ids = new Set(batch.map((e) => e.eventId));
580
+ this.events = this.events.filter((e) => !ids.has(e.eventId));
581
+ this.persist();
582
+ }
583
+ }
584
+ clear() {
585
+ this.events = [];
586
+ this.persist();
587
+ }
588
+ destroy() {
589
+ this.destroyed = true;
590
+ if (this.timer) clearTimeout(this.timer);
591
+ if (this.retryTimer) clearTimeout(this.retryTimer);
592
+ this.timer = null;
593
+ this.retryTimer = null;
594
+ this.persist();
595
+ }
596
+ };
597
+ var Emitter = class {
598
+ constructor(onError) {
599
+ __publicField2(this, "onError", onError);
600
+ __publicField2(this, "listeners", /* @__PURE__ */ new Map());
601
+ }
602
+ on(event, cb) {
603
+ let set = this.listeners.get(event);
604
+ if (!set) {
605
+ set = /* @__PURE__ */ new Set();
606
+ this.listeners.set(event, set);
607
+ }
608
+ set.add(cb);
609
+ return () => this.off(event, cb);
610
+ }
611
+ off(event, cb) {
612
+ this.listeners.get(event)?.delete(cb);
613
+ }
614
+ emit(event, ...args) {
615
+ const set = this.listeners.get(event);
616
+ if (!set) return;
617
+ for (const cb of Array.from(set)) {
618
+ try {
619
+ cb(...args);
620
+ } catch (err) {
621
+ this.onError?.(err);
622
+ }
623
+ }
624
+ }
625
+ clear() {
626
+ this.listeners.clear();
627
+ }
628
+ };
629
+ var HOUR = 3600 * 1e3;
630
+ function isFrequencyEligible(freq, state, shownThisSession, now = Date.now()) {
631
+ const s = state ?? { impressions: 0 };
632
+ if (freq.maxImpressions != null && s.impressions >= freq.maxImpressions) return false;
633
+ if (s.completedAt && freq.mode !== "every_time") return false;
634
+ if (s.dismissedAt) {
635
+ if (!freq.reshowAfterDismiss) return false;
636
+ if (freq.cooldownHours > 0 && now - Date.parse(s.dismissedAt) < freq.cooldownHours * HOUR) return false;
637
+ }
638
+ switch (freq.mode) {
639
+ case "once":
640
+ return s.impressions === 0 || !!s.dismissedAt && freq.reshowAfterDismiss;
641
+ case "once_per_session":
642
+ return !shownThisSession;
643
+ case "interval": {
644
+ if (!s.lastShownAt) return true;
645
+ const hours = freq.intervalHours ?? 24;
646
+ return now - Date.parse(s.lastShownAt) >= hours * HOUR;
647
+ }
648
+ case "every_time":
649
+ default:
650
+ return true;
651
+ }
652
+ }
653
+ function isExclusiveGuide(guide) {
654
+ if (guide.kind === "banner" || guide.kind === "checklist") return false;
655
+ const steps = guide.config.steps;
656
+ if (steps.length > 0 && steps.every((s) => s.type === "hotspot" || s.type === "banner")) return false;
657
+ return true;
658
+ }
659
+ function areMutuallyExclusive(a, b) {
660
+ const la = a.config.frequency.mutuallyExclusiveWith;
661
+ const lb = b.config.frequency.mutuallyExclusiveWith;
662
+ return la.includes(b.id) || la.includes(b.key) || lb.includes(a.id) || lb.includes(a.key);
663
+ }
664
+ function isWithinSchedule(guide, now = Date.now()) {
665
+ const s = guide.config.survey;
666
+ if (!s) return true;
667
+ if (s.startAt && now < Date.parse(s.startAt)) return false;
668
+ if (s.endAt && now > Date.parse(s.endAt)) return false;
669
+ return true;
670
+ }
671
+
672
+ // src/core/targets.ts
673
+ var TargetTimeoutError = class extends Error {
674
+ constructor(ids) {
675
+ super(`[Guidely] target not found: ${ids.join(", ") || "(no mobile-compatible selector)"}`);
676
+ this.name = "TargetTimeoutError";
677
+ }
678
+ };
679
+ function targetIdsFor(selectors) {
680
+ const ids = [];
681
+ for (const s of selectors) {
682
+ let id = null;
683
+ if (s.kind === "guidely-id" || s.kind === "id") id = s.value.trim().replace(/^#/, "") || null;
684
+ else if (s.kind === "css" || s.kind === "data-attr") id = guidelyIdFromSelector(s.value);
685
+ if (id && !ids.includes(id)) ids.push(id);
686
+ }
687
+ return ids;
688
+ }
689
+ var TargetRegistry = class {
690
+ constructor() {
691
+ __publicField(this, "targets", /* @__PURE__ */ new Map());
692
+ __publicField(this, "changeListeners", /* @__PURE__ */ new Set());
693
+ __publicField(this, "tapListeners", /* @__PURE__ */ new Set());
694
+ __publicField(this, "viewport", null);
695
+ __publicField(this, "screen", null);
696
+ }
697
+ register(id, handle) {
698
+ const list = this.targets.get(id) ?? [];
699
+ list.push(handle);
700
+ this.targets.set(id, list);
701
+ this.changed();
702
+ return () => {
703
+ const cur = this.targets.get(id);
704
+ if (!cur) return;
705
+ const next = cur.filter((h) => h !== handle);
706
+ if (next.length) this.targets.set(id, next);
707
+ else this.targets.delete(id);
708
+ this.changed();
709
+ };
710
+ }
711
+ /** Window size used for the on-screen check. */
712
+ setViewport(width, height) {
713
+ this.viewport = { width, height };
714
+ }
715
+ /** The current screen; targets scoped to other screens are treated as hidden. */
716
+ setScreen(name) {
717
+ if (name === this.screen) return;
718
+ this.screen = name;
719
+ this.changed();
720
+ }
721
+ has(id) {
722
+ return this.targets.has(id);
723
+ }
724
+ ids() {
725
+ return Array.from(this.targets.keys());
726
+ }
727
+ /** Call when a target's layout may have changed (onLayout, scroll). */
728
+ changed() {
729
+ for (const cb of Array.from(this.changeListeners)) {
730
+ try {
731
+ cb();
732
+ } catch {
733
+ }
734
+ }
735
+ }
736
+ onChange(cb) {
737
+ this.changeListeners.add(cb);
738
+ return () => this.changeListeners.delete(cb);
739
+ }
740
+ tapped(id) {
741
+ for (const cb of Array.from(this.tapListeners)) {
742
+ try {
743
+ cb(id);
744
+ } catch {
745
+ }
746
+ }
747
+ }
748
+ onTap(cb) {
749
+ this.tapListeners.add(cb);
750
+ return () => this.tapListeners.delete(cb);
751
+ }
752
+ /** Frame of a visible target (the most recently mounted visible one when an id is used twice), else null. */
753
+ async measure(id) {
754
+ const list = this.targets.get(id) ?? [];
755
+ for (let i = list.length - 1; i >= 0; i--) {
756
+ const handle = list[i];
757
+ if (handle.screen && this.screen && handle.screen !== this.screen) continue;
758
+ const r = await this.measureHandle(handle);
759
+ if (r) return r;
760
+ }
761
+ return null;
762
+ }
763
+ async measureHandle(handle) {
764
+ try {
765
+ const r = await handle.measure();
766
+ if (!r || !(r.width > 0) || !(r.height > 0)) return null;
767
+ const vp = this.viewport;
768
+ if (vp && (r.x + r.width <= 0 || r.y + r.height <= 0 || r.x >= vp.width || r.y >= vp.height)) return null;
769
+ return r;
770
+ } catch {
771
+ return null;
772
+ }
773
+ }
774
+ /** First of `ids` that is mounted and visible. */
775
+ async resolve(ids) {
776
+ for (const id of ids) {
777
+ const rect = await this.measure(id);
778
+ if (rect) return { id, rect };
779
+ }
780
+ return null;
781
+ }
782
+ /** Resolves when one of `ids` is visible; `timeoutMs` 0 waits forever. Re-checks on registry changes and every 300ms. */
783
+ waitFor(ids, options) {
784
+ return new Promise((resolve, reject) => {
785
+ let done = false;
786
+ let checking = false;
787
+ let again = false;
788
+ const timers = [];
789
+ const finish = (fn) => {
790
+ if (done) return;
791
+ done = true;
792
+ off();
793
+ timers.forEach((t) => clearTimeout(t));
794
+ timers.forEach((t) => clearInterval(t));
795
+ options.signal?.removeEventListener?.("abort", onAbort);
796
+ fn();
797
+ };
798
+ const check = async () => {
799
+ if (done) return;
800
+ if (checking) {
801
+ again = true;
802
+ return;
803
+ }
804
+ checking = true;
805
+ const hit = ids.length ? await this.resolve(ids) : null;
806
+ checking = false;
807
+ if (hit) return finish(() => resolve(hit));
808
+ if (again) {
809
+ again = false;
810
+ void check();
811
+ }
812
+ };
813
+ const onAbort = () => finish(() => reject(Object.assign(new Error("aborted"), { name: "AbortError" })));
814
+ if (options.signal?.aborted) return onAbort();
815
+ options.signal?.addEventListener?.("abort", onAbort);
816
+ const off = this.onChange(() => void check());
817
+ timers.push(setInterval(() => void check(), 300));
818
+ if (options.timeoutMs > 0) timers.push(setTimeout(() => finish(() => reject(new TargetTimeoutError(ids))), options.timeoutMs));
819
+ void check();
820
+ });
821
+ }
822
+ };
823
+
824
+ // src/core/controller.ts
825
+ var ANCHORED = /* @__PURE__ */ new Set(["tooltip", "popover", "spotlight", "hotspot"]);
826
+ var isAnchoredStep = (step) => ANCHORED.has(step.type);
827
+ function visibleButtons(step, canGoBack) {
828
+ return canGoBack ? step.buttons : step.buttons.filter((b) => b.action !== "back");
829
+ }
830
+ function screenMatches(pattern, url) {
831
+ if (!pattern) return true;
832
+ if (matchUrlPattern(pattern, url)) return true;
833
+ if (!pattern.includes("?") && !pattern.includes("#")) return matchUrlPattern(pattern, url.split(/[?#]/)[0] ?? url);
834
+ return false;
835
+ }
836
+ function runGuide(guide, o) {
837
+ const steps = guide.config.steps;
838
+ const total = steps.length;
839
+ const onError = o.onError ?? (() => {
840
+ });
841
+ let status = "idle";
842
+ let index = -1;
843
+ let pendingIndex = -1;
844
+ let rendered = false;
845
+ let started = false;
846
+ let stepDone = false;
847
+ let ended = false;
848
+ const history = [];
849
+ const stepCleanups = [];
850
+ const emit = (name, step, stepIndex, properties) => {
851
+ try {
852
+ o.onEvent({ name, stepId: step?.id, stepIndex, properties });
853
+ } catch (err) {
854
+ onError(err);
855
+ }
856
+ };
857
+ const clearStep = () => {
858
+ stepCleanups.splice(0).forEach((fn) => {
859
+ try {
860
+ fn();
861
+ } catch {
862
+ }
863
+ });
864
+ if (rendered) {
865
+ rendered = false;
866
+ try {
867
+ o.onClear();
868
+ } catch (err) {
869
+ onError(err);
870
+ }
871
+ }
872
+ stepDone = false;
873
+ };
874
+ const end = (reason) => {
875
+ if (ended) return;
876
+ ended = true;
877
+ const step = steps[index];
878
+ clearStep();
879
+ status = "ended";
880
+ if (reason === "completed") emit("guide_completed", step, index);
881
+ else if (reason === "dismissed") emit("guide_dismissed", step, index);
882
+ else if (reason === "skipped") emit("guide_skipped", step, index);
883
+ try {
884
+ o.onEnd(reason);
885
+ } catch (err) {
886
+ onError(err);
887
+ }
888
+ };
889
+ const completeStep = () => {
890
+ if (stepDone || index < 0) return;
891
+ stepDone = true;
892
+ emit("step_completed", steps[index], index);
893
+ };
894
+ const goNext = (overrideStepId) => {
895
+ const step = steps[index];
896
+ let target = -1;
897
+ const ctx = o.ctx();
898
+ if (step) {
899
+ for (const branch of step.branches) {
900
+ if (evaluateRules(branch.when, ctx)) {
901
+ target = steps.findIndex((s) => s.id === branch.goTo);
902
+ if (target >= 0) break;
903
+ }
904
+ }
905
+ }
906
+ if (target < 0 && overrideStepId) target = steps.findIndex((s) => s.id === overrideStepId);
907
+ if (target < 0) target = index + 1;
908
+ if (target >= total) return end("completed");
909
+ history.push(index);
910
+ showStep(target, 1);
911
+ };
912
+ const advance = () => {
913
+ if (ended) return;
914
+ completeStep();
915
+ goNext();
916
+ };
917
+ const press = (b) => {
918
+ try {
919
+ switch (b.action) {
920
+ case "next":
921
+ completeStep();
922
+ goNext(b.value);
923
+ break;
924
+ case "back":
925
+ controller.back();
926
+ break;
927
+ case "dismiss":
928
+ end("dismissed");
929
+ break;
930
+ case "complete":
931
+ completeStep();
932
+ end("completed");
933
+ break;
934
+ case "skip":
935
+ end("skipped");
936
+ break;
937
+ case "url":
938
+ completeStep();
939
+ o.onNavigate(b.value || "");
940
+ if (index + 1 >= total) end("completed");
941
+ else goNext();
942
+ break;
943
+ case "start_guide":
944
+ completeStep();
945
+ end("completed");
946
+ o.onAction("start_guide", b.value);
947
+ break;
948
+ case "event":
949
+ o.onAction("event", b.value);
950
+ break;
951
+ case "open_resource_center":
952
+ o.onAction("open_resource_center", b.value);
953
+ break;
954
+ default:
955
+ break;
956
+ }
957
+ } catch (err) {
958
+ onError(err);
959
+ }
960
+ };
961
+ const setupAdvance = (step, targetId) => {
962
+ const modes = step.advanceOn === "any" ? ["element_click", "element_visible", "event", "delay"] : [step.advanceOn];
963
+ for (const mode of modes) {
964
+ const id = guidelyIdFromSelector(step.advanceValue) ?? (mode === "element_click" ? targetId : null);
965
+ if (mode === "element_click" && id) {
966
+ stepCleanups.push(o.targets.onTap((tapped) => tapped === id && advance()));
967
+ } else if (mode === "element_visible" && id) {
968
+ const ac = new AbortController();
969
+ stepCleanups.push(() => ac.abort());
970
+ o.targets.waitFor([id], { timeoutMs: 0, signal: ac.signal }).then(() => !ac.signal.aborted && advance(), () => {
971
+ });
972
+ } else if (mode === "event" && step.advanceValue) {
973
+ stepCleanups.push(o.eventBus.on(step.advanceValue, advance));
974
+ } else if (mode === "delay") {
975
+ const t = setTimeout(advance, step.advanceDelayMs ?? 3e3);
976
+ stepCleanups.push(() => clearTimeout(t));
977
+ }
978
+ }
979
+ };
980
+ const render = (step, targetId, asModal = false) => {
981
+ if (ended) return;
982
+ status = "running";
983
+ try {
984
+ o.onRender({ step, index, total, canGoBack: history.length > 0 || index > 0, targetId, asModal });
985
+ rendered = true;
986
+ } catch (err) {
987
+ onError(err);
988
+ if (index + 1 < total) return goNext();
989
+ return end("skipped");
990
+ }
991
+ if (!started) {
992
+ started = true;
993
+ emit("guide_started", step, index);
994
+ }
995
+ emit("step_viewed", step, index, asModal ? { fallback: "show_as_modal" } : void 0);
996
+ setupAdvance(step, targetId);
997
+ };
998
+ const applyFallback = (step, direction) => {
999
+ const fallback = step.target?.fallback ?? "skip";
1000
+ emit("step_viewed", step, index, { fallback, targetMissing: true });
1001
+ if (fallback === "show_as_modal") return render(step, null, true);
1002
+ if (fallback === "end_guide") return end("skipped");
1003
+ const nextIndex = index + direction;
1004
+ if (nextIndex < 0) return showStep(0, 1);
1005
+ if (nextIndex >= total) return end("completed");
1006
+ showStep(nextIndex, direction);
1007
+ };
1008
+ function showStep(i, direction = 1) {
1009
+ if (ended) return;
1010
+ clearStep();
1011
+ if (i < 0) i = 0;
1012
+ if (i >= total) return end("completed");
1013
+ index = i;
1014
+ const step = steps[i];
1015
+ if (step.targeting && step.targeting.rules.length && !evaluateRules(step.targeting, o.ctx())) {
1016
+ const nextIndex = i + direction;
1017
+ if (nextIndex >= total) return end("completed");
1018
+ if (nextIndex < 0) return;
1019
+ return showStep(nextIndex, direction);
1020
+ }
1021
+ if (step.url && !screenMatches(step.url, o.currentUrl())) {
1022
+ status = "waiting_url";
1023
+ pendingIndex = i;
1024
+ return;
1025
+ }
1026
+ pendingIndex = -1;
1027
+ if (isAnchoredStep(step) && step.target) {
1028
+ status = "waiting_target";
1029
+ const ids = targetIdsFor(step.target.selectors);
1030
+ if (!ids.length) return applyFallback(step, direction);
1031
+ const ac = new AbortController();
1032
+ stepCleanups.push(() => ac.abort());
1033
+ const timeoutMs = step.target.fallback === "wait" ? 0 : step.target.waitMs;
1034
+ o.targets.waitFor(ids, { timeoutMs, signal: ac.signal }).then(
1035
+ (hit) => {
1036
+ if (ac.signal.aborted || ended) return;
1037
+ render(step, hit.id);
1038
+ },
1039
+ (err) => {
1040
+ if (ac.signal.aborted || ended || err?.name === "AbortError") return;
1041
+ applyFallback(step, direction);
1042
+ }
1043
+ );
1044
+ return;
1045
+ }
1046
+ render(step, null);
1047
+ }
1048
+ const controller = {
1049
+ guide,
1050
+ start() {
1051
+ if (status !== "idle") return;
1052
+ emit("guide_viewed");
1053
+ if (total === 0) return end("completed");
1054
+ let startIndex = o.startAtStep ? steps.findIndex((s) => s.id === o.startAtStep) : 0;
1055
+ if (startIndex < 0) startIndex = 0;
1056
+ showStep(startIndex, 1);
1057
+ },
1058
+ next() {
1059
+ if (ended || index < 0) return;
1060
+ completeStep();
1061
+ goNext();
1062
+ },
1063
+ back() {
1064
+ if (ended) return;
1065
+ const prev = history.pop();
1066
+ if (prev == null) {
1067
+ if (index > 0) showStep(index - 1, -1);
1068
+ return;
1069
+ }
1070
+ showStep(prev, -1);
1071
+ },
1072
+ goTo(stepId) {
1073
+ const i = steps.findIndex((s) => s.id === stepId);
1074
+ if (i >= 0 && !ended) {
1075
+ if (index >= 0) history.push(index);
1076
+ showStep(i, 1);
1077
+ }
1078
+ },
1079
+ dismiss: () => end("dismissed"),
1080
+ complete() {
1081
+ completeStep();
1082
+ end("completed");
1083
+ },
1084
+ skip: () => end("skipped"),
1085
+ press,
1086
+ resume() {
1087
+ if (ended) return;
1088
+ if (status === "waiting_url" && pendingIndex >= 0) return showStep(pendingIndex, 1);
1089
+ const step = steps[index];
1090
+ if (step?.url && status === "running" && !screenMatches(step.url, o.currentUrl())) showStep(index, 1);
1091
+ },
1092
+ destroy() {
1093
+ if (ended) return;
1094
+ ended = true;
1095
+ clearStep();
1096
+ status = "ended";
1097
+ try {
1098
+ o.onEnd("destroyed");
1099
+ } catch (err) {
1100
+ onError(err);
1101
+ }
1102
+ },
1103
+ getState: () => ({ status, stepId: steps[index]?.id ?? null, stepIndex: index, total })
1104
+ };
1105
+ return controller;
1106
+ }
1107
+
1108
+ // src/core/messenger.ts
1109
+ var TOKEN_HEADER = "X-Guidely-Conversation";
1110
+ var MAX_STORED = 50;
1111
+ function createSseParser(onEvent) {
1112
+ let buffer = "";
1113
+ const dispatch = (raw) => {
1114
+ let event = "message";
1115
+ const data = [];
1116
+ for (const line of raw.split("\n")) {
1117
+ if (!line || line.startsWith(":")) continue;
1118
+ const i = line.indexOf(":");
1119
+ const field = i < 0 ? line : line.slice(0, i);
1120
+ const value = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
1121
+ if (field === "event") event = value;
1122
+ else if (field === "data") data.push(value);
1123
+ }
1124
+ if (!data.length) return;
1125
+ try {
1126
+ onEvent(event, JSON.parse(data.join("\n")));
1127
+ } catch {
1128
+ }
1129
+ };
1130
+ return {
1131
+ feed(chunk) {
1132
+ buffer += chunk.replace(/\r\n?/g, "\n");
1133
+ let sep;
1134
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
1135
+ dispatch(buffer.slice(0, sep));
1136
+ buffer = buffer.slice(sep + 2);
1137
+ }
1138
+ },
1139
+ end() {
1140
+ if (buffer.trim()) dispatch(buffer);
1141
+ buffer = "";
1142
+ }
1143
+ };
1144
+ }
1145
+ function xhrStreamPost() {
1146
+ if (typeof XMLHttpRequest === "undefined") return null;
1147
+ return (url, headers, body, onChunk) => new Promise((resolve, reject) => {
1148
+ const xhr = new XMLHttpRequest();
1149
+ let seen = 0;
1150
+ let streamed = false;
1151
+ const pump = () => {
1152
+ const type = xhr.getResponseHeader("content-type") || "";
1153
+ if (!type.includes("text/event-stream")) return;
1154
+ streamed = true;
1155
+ const text = xhr.responseText || "";
1156
+ if (text.length > seen) {
1157
+ onChunk(text.slice(seen));
1158
+ seen = text.length;
1159
+ }
1160
+ };
1161
+ xhr.open("POST", url);
1162
+ for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
1163
+ xhr.onprogress = pump;
1164
+ xhr.onload = () => {
1165
+ pump();
1166
+ let json = null;
1167
+ if (!streamed) {
1168
+ try {
1169
+ json = JSON.parse(xhr.responseText || "null");
1170
+ } catch {
1171
+ json = null;
1172
+ }
1173
+ }
1174
+ resolve({ status: xhr.status, json, streamed });
1175
+ };
1176
+ xhr.onerror = () => reject(new Error("Network request failed"));
1177
+ xhr.send(JSON.stringify(body));
1178
+ });
1179
+ }
1180
+ function fetchStreamPost(fetchImpl) {
1181
+ return async (url, headers, body, onChunk) => {
1182
+ const res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
1183
+ const type = res.headers.get("content-type") || "";
1184
+ const text = await res.text();
1185
+ if (type.includes("text/event-stream")) {
1186
+ onChunk(text);
1187
+ return { status: res.status, json: null, streamed: true };
1188
+ }
1189
+ let json = null;
1190
+ try {
1191
+ json = JSON.parse(text || "null");
1192
+ } catch {
1193
+ json = null;
1194
+ }
1195
+ return { status: res.status, json, streamed: false };
1196
+ };
1197
+ }
1198
+ var byNewest = (a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt);
1199
+ var Messenger = class {
1200
+ constructor(config, deps) {
1201
+ __publicField(this, "deps", deps);
1202
+ __publicField(this, "snapshot");
1203
+ __publicField(this, "listeners", /* @__PURE__ */ new Set());
1204
+ __publicField(this, "threadTimer", null);
1205
+ __publicField(this, "backgroundTimer", null);
1206
+ __publicField(this, "foreground", true);
1207
+ __publicField(this, "destroyed", false);
1208
+ __publicField(this, "poll");
1209
+ this.poll = deps.pollMs ?? { thread: 5e3, background: 6e4 };
1210
+ this.snapshot = { config, open: false, view: { kind: "tab", tab: "home" }, conversations: [], thread: null, unread: 0, articles: null, article: null };
1211
+ void this.refreshConversations();
1212
+ this.startBackgroundPolling();
1213
+ }
1214
+ // -------------------------------------------------------------------------- state
1215
+ getSnapshot() {
1216
+ return this.snapshot;
1217
+ }
1218
+ subscribe(cb) {
1219
+ this.listeners.add(cb);
1220
+ return () => this.listeners.delete(cb);
1221
+ }
1222
+ set(patch) {
1223
+ if (this.destroyed) return;
1224
+ const next = { ...this.snapshot, ...patch };
1225
+ next.unread = next.conversations.filter((c) => c.unread > 0).length;
1226
+ this.snapshot = next;
1227
+ for (const cb of Array.from(this.listeners)) {
1228
+ try {
1229
+ cb();
1230
+ } catch (err) {
1231
+ this.deps.onError(err);
1232
+ }
1233
+ }
1234
+ }
1235
+ setThread(patch) {
1236
+ const t = this.snapshot.thread;
1237
+ if (!t) return;
1238
+ this.set({ thread: { ...t, ...patch } });
1239
+ }
1240
+ updateConfig(config) {
1241
+ this.set({ config });
1242
+ }
1243
+ // -------------------------------------------------------------------------- tokens
1244
+ stored() {
1245
+ const list = readJson(this.deps.store, KEYS.conversations(this.deps.publicKey), []);
1246
+ return Array.isArray(list) ? list.filter((c) => c && typeof c.id === "string" && typeof c.token === "string") : [];
1247
+ }
1248
+ remember(id, token) {
1249
+ writeJson(this.deps.store, KEYS.conversations(this.deps.publicKey), [{ id, token }, ...this.stored().filter((c) => c.id !== id)].slice(0, MAX_STORED));
1250
+ }
1251
+ tokenFor(id) {
1252
+ return this.stored().find((c) => c.id === id)?.token ?? "";
1253
+ }
1254
+ /** Forgets every conversation (logout). */
1255
+ forgetAll() {
1256
+ this.deps.store.remove(KEYS.conversations(this.deps.publicKey));
1257
+ this.stopThreadPolling();
1258
+ this.set({ open: false, view: { kind: "tab", tab: "home" }, conversations: [], thread: null, article: null });
1259
+ }
1260
+ // -------------------------------------------------------------------------- http
1261
+ url(path, params = {}) {
1262
+ const query = Object.entries({ key: this.deps.publicKey, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
1263
+ return `${this.deps.baseUrl.replace(/\/$/, "")}/api/v1/sdk${path}?${query}`;
1264
+ }
1265
+ headers(extra = {}) {
1266
+ return { "X-Guidely-Key": this.deps.publicKey, Accept: "application/json", "Content-Type": "application/json", ...extra };
1267
+ }
1268
+ async request(method, path, opts = {}) {
1269
+ const headers = this.headers(opts.conversationId ? { [TOKEN_HEADER]: this.tokenFor(opts.conversationId) } : {});
1270
+ const res = await this.deps.fetch(this.url(path, opts.params), { method, headers, body: opts.body === void 0 ? void 0 : JSON.stringify(opts.body) });
1271
+ if (!res.ok) throw new Error(`Request failed (${res.status})`);
1272
+ return await res.json();
1273
+ }
1274
+ // -------------------------------------------------------------------------- navigation
1275
+ open(tab) {
1276
+ const wasOpen = this.snapshot.open;
1277
+ this.set({ open: true, ...tab ? { view: { kind: "tab", tab } } : {} });
1278
+ if (!wasOpen) {
1279
+ this.deps.onOpen();
1280
+ void this.refreshConversations();
1281
+ }
1282
+ if (tab === "help" || this.snapshot.view.kind === "tab") this.onView();
1283
+ }
1284
+ close() {
1285
+ this.stopThreadPolling();
1286
+ this.set({ open: false });
1287
+ }
1288
+ setTab(tab) {
1289
+ this.stopThreadPolling();
1290
+ this.set({ view: { kind: "tab", tab }, thread: null });
1291
+ this.onView();
1292
+ }
1293
+ onView() {
1294
+ const v = this.snapshot.view;
1295
+ if (v.kind === "tab" && v.tab === "help" && !this.snapshot.articles) void this.loadArticles();
1296
+ if (v.kind === "tab" && v.tab === "messages") void this.refreshConversations();
1297
+ }
1298
+ /** Opens a new thread (optionally pre-filling the composer). */
1299
+ showNewMessage(text = "") {
1300
+ this.stopThreadPolling();
1301
+ if (!this.snapshot.open) this.deps.onOpen();
1302
+ this.set({ open: true, view: { kind: "thread", conversationId: null }, thread: { conversation: null, messages: [], streaming: {}, sending: false, error: null, draft: text } });
1303
+ }
1304
+ async openConversation(id) {
1305
+ const known = this.snapshot.conversations.find((c) => c.id === id) ?? null;
1306
+ this.stopThreadPolling();
1307
+ this.set({ open: true, view: { kind: "thread", conversationId: id }, thread: { conversation: known, messages: [], streaming: {}, sending: false, error: null, draft: "" } });
1308
+ try {
1309
+ const res = await this.request("GET", `/conversations/${encodeURIComponent(id)}/messages`, { conversationId: id });
1310
+ this.markRead(res.conversation);
1311
+ this.setThread({ conversation: { ...res.conversation, unread: 0 }, messages: res.messages });
1312
+ } catch (err) {
1313
+ this.setThread({ error: "Couldn't load this conversation." });
1314
+ this.deps.onError(err);
1315
+ }
1316
+ this.scheduleThreadPoll();
1317
+ }
1318
+ back() {
1319
+ const v = this.snapshot.view;
1320
+ if (v.kind === "article") return this.set({ view: v.from, article: null });
1321
+ this.setTab(this.snapshot.thread?.conversation ? "messages" : "home");
1322
+ }
1323
+ // -------------------------------------------------------------------------- conversations
1324
+ upsertConversation(c) {
1325
+ const list = [c, ...this.snapshot.conversations.filter((x) => x.id !== c.id)].sort(byNewest);
1326
+ this.set({ conversations: list });
1327
+ }
1328
+ markRead(c) {
1329
+ this.upsertConversation({ ...c, unread: 0 });
1330
+ }
1331
+ async refreshConversations() {
1332
+ const stored = this.stored();
1333
+ if (!stored.length) return;
1334
+ try {
1335
+ const res = await this.request("POST", "/conversations/lookup", { body: { tokens: stored.map((c) => c.token) } });
1336
+ const known = new Set(res.conversations.map((c) => c.id));
1337
+ if (stored.some((c) => !known.has(c.id))) writeJson(this.deps.store, KEYS.conversations(this.deps.publicKey), stored.filter((c) => known.has(c.id)));
1338
+ const openId = this.snapshot.view.kind === "thread" ? this.snapshot.view.conversationId : null;
1339
+ const list = res.conversations.map(({ token: _token, ...c }) => c.id === openId && this.snapshot.open ? { ...c, unread: 0 } : c).sort(byNewest);
1340
+ this.set({ conversations: list });
1341
+ } catch (err) {
1342
+ this.deps.onError(err);
1343
+ }
1344
+ }
1345
+ /** Sends the composer text: starts a conversation or posts to the open one. Streams Scout's reply. */
1346
+ async send(text) {
1347
+ const body = text.trim().slice(0, 4e3);
1348
+ const thread = this.snapshot.thread;
1349
+ if (!body || !thread || thread.sending) return;
1350
+ this.setThread({ sending: true, error: null, draft: "" });
1351
+ const conversationId = thread.conversation?.id ?? null;
1352
+ const path = conversationId ? `/conversations/${encodeURIComponent(conversationId)}/messages` : "/conversations";
1353
+ const payload = conversationId ? { message: body } : { anonymousId: this.deps.anonymousId(), userId: this.deps.userId(), message: body, pageUrl: this.deps.pageUrl() };
1354
+ const headers = { ...this.headers(conversationId ? { [TOKEN_HEADER]: this.tokenFor(conversationId) } : {}), Accept: "text/event-stream, application/json" };
1355
+ let finished = false;
1356
+ const parser = createSseParser((event, data) => {
1357
+ if (event === "conversation") {
1358
+ const d = data;
1359
+ this.remember(d.conversation.id, d.token);
1360
+ this.markRead(d.conversation);
1361
+ this.set({ view: { kind: "thread", conversationId: d.conversation.id } });
1362
+ this.setThread({ conversation: { ...d.conversation, unread: 0 } });
1363
+ d.messages.forEach((m) => this.addMessage(m));
1364
+ } else if (event === "message") this.addMessage(data);
1365
+ else if (event === "delta") {
1366
+ const d = data;
1367
+ const t = this.snapshot.thread;
1368
+ if (t) this.setThread({ streaming: { ...t.streaming, [d.messageId]: (t.streaming[d.messageId] ?? "") + d.text } });
1369
+ } else if (event === "done") {
1370
+ finished = true;
1371
+ const d = data;
1372
+ if (d.message) this.addMessage(d.message);
1373
+ this.markRead(d.conversation);
1374
+ this.setThread({ conversation: { ...d.conversation, unread: 0 } });
1375
+ } else if (event === "error") {
1376
+ this.setThread({ error: data?.message ?? "Something went wrong" });
1377
+ }
1378
+ });
1379
+ try {
1380
+ const res = await this.deps.streamPost(this.url(path), headers, payload, (chunk) => parser.feed(chunk));
1381
+ parser.end();
1382
+ if (res.status < 200 || res.status >= 300) throw new Error(`Request failed (${res.status})`);
1383
+ if (!res.streamed && res.json) {
1384
+ const d = res.json;
1385
+ if ("token" in d) {
1386
+ this.remember(d.conversation.id, d.token);
1387
+ this.set({ view: { kind: "thread", conversationId: d.conversation.id } });
1388
+ }
1389
+ this.markRead(d.conversation);
1390
+ this.setThread({ conversation: { ...d.conversation, unread: 0 } });
1391
+ d.messages.forEach((m) => this.addMessage(m));
1392
+ } else if (res.streamed && !finished) this.setThread({ error: "The reply was interrupted. Pull to refresh in a moment." });
1393
+ } catch (err) {
1394
+ this.setThread({ error: "Your message wasn't sent. Check your connection and try again.", draft: body });
1395
+ this.deps.onError(err);
1396
+ } finally {
1397
+ const t = this.snapshot.thread;
1398
+ if (t) this.setThread({ sending: false, streaming: {} });
1399
+ this.scheduleThreadPoll();
1400
+ }
1401
+ }
1402
+ addMessage(m) {
1403
+ const t = this.snapshot.thread;
1404
+ if (!t) return;
1405
+ const exists = t.messages.some((x) => x.id === m.id);
1406
+ const messages = exists ? t.messages.map((x) => x.id === m.id ? m : x) : [...t.messages, m];
1407
+ const streaming = { ...t.streaming };
1408
+ if (m.status !== "pending") delete streaming[m.id];
1409
+ this.setThread({ messages, streaming });
1410
+ if (!exists && m.status !== "pending" && (m.author === "scout" || m.author === "agent") && t.conversation) this.deps.onMessage(m, t.conversation);
1411
+ }
1412
+ async handoff() {
1413
+ const id = this.snapshot.thread?.conversation?.id;
1414
+ if (!id) return;
1415
+ try {
1416
+ const res = await this.request("POST", `/conversations/${encodeURIComponent(id)}/handoff`, { body: {}, conversationId: id });
1417
+ this.markRead(res.conversation);
1418
+ this.setThread({ conversation: { ...res.conversation, unread: 0 } });
1419
+ res.messages.forEach((m) => this.addMessage(m));
1420
+ } catch (err) {
1421
+ this.setThread({ error: "Couldn't reach the team. Try again." });
1422
+ this.deps.onError(err);
1423
+ }
1424
+ }
1425
+ async rate(rating) {
1426
+ const c = this.snapshot.thread?.conversation;
1427
+ if (!c) return;
1428
+ this.setThread({ conversation: { ...c, rating } });
1429
+ try {
1430
+ await this.request("POST", `/conversations/${encodeURIComponent(c.id)}/rating`, { body: { rating }, conversationId: c.id });
1431
+ } catch (err) {
1432
+ this.deps.onError(err);
1433
+ }
1434
+ }
1435
+ startGuide(key) {
1436
+ this.close();
1437
+ this.deps.onStartGuide(key);
1438
+ }
1439
+ // -------------------------------------------------------------------------- help
1440
+ async loadArticles() {
1441
+ try {
1442
+ this.set({ articles: await this.request("GET", "/articles") });
1443
+ } catch (err) {
1444
+ this.deps.onError(err);
1445
+ }
1446
+ }
1447
+ async openArticle(id) {
1448
+ const from = this.snapshot.view.kind === "article" ? this.snapshot.view.from : this.snapshot.view;
1449
+ this.stopThreadPolling();
1450
+ this.set({ open: true, view: { kind: "article", articleId: id, from }, article: null });
1451
+ try {
1452
+ const res = await this.request("GET", `/articles/${encodeURIComponent(id)}`);
1453
+ this.set({ article: res.article });
1454
+ } catch (err) {
1455
+ this.deps.onError(err);
1456
+ }
1457
+ }
1458
+ async search(q) {
1459
+ try {
1460
+ return (await this.request("GET", "/resource-center/search", { params: { q } })).articles;
1461
+ } catch {
1462
+ return [];
1463
+ }
1464
+ }
1465
+ // -------------------------------------------------------------------------- polling
1466
+ /** Called with app foreground/background changes; polling only runs in the foreground. */
1467
+ setForeground(active) {
1468
+ this.foreground = active;
1469
+ if (!active) {
1470
+ this.stopThreadPolling();
1471
+ return;
1472
+ }
1473
+ void this.refreshConversations();
1474
+ if (this.snapshot.open && this.snapshot.view.kind === "thread") this.scheduleThreadPoll();
1475
+ }
1476
+ startBackgroundPolling() {
1477
+ this.backgroundTimer = setInterval(() => {
1478
+ if (this.foreground && !(this.snapshot.open && this.snapshot.view.kind === "thread")) void this.refreshConversations();
1479
+ }, this.poll.background);
1480
+ }
1481
+ scheduleThreadPoll() {
1482
+ this.stopThreadPolling();
1483
+ if (this.destroyed || !this.foreground) return;
1484
+ this.threadTimer = setTimeout(() => void this.pollThread(), this.poll.thread);
1485
+ }
1486
+ stopThreadPolling() {
1487
+ if (this.threadTimer) clearTimeout(this.threadTimer);
1488
+ this.threadTimer = null;
1489
+ }
1490
+ async pollThread() {
1491
+ const t = this.snapshot.thread;
1492
+ const id = t?.conversation?.id;
1493
+ if (!t || !id || !this.snapshot.open || t.sending) return this.scheduleThreadPoll();
1494
+ const since = t.messages[t.messages.length - 1]?.createdAt;
1495
+ try {
1496
+ const res = await this.request("GET", `/conversations/${encodeURIComponent(id)}/messages`, { params: { since }, conversationId: id });
1497
+ if (this.snapshot.thread?.conversation?.id !== id) return;
1498
+ this.markRead(res.conversation);
1499
+ this.setThread({ conversation: { ...res.conversation, unread: 0 } });
1500
+ res.messages.forEach((m) => this.addMessage(m));
1501
+ } catch (err) {
1502
+ this.deps.onError(err);
1503
+ }
1504
+ this.scheduleThreadPoll();
1505
+ }
1506
+ destroy() {
1507
+ this.destroyed = true;
1508
+ this.stopThreadPolling();
1509
+ if (this.backgroundTimer) clearInterval(this.backgroundTimer);
1510
+ this.listeners.clear();
1511
+ }
1512
+ };
1513
+
1514
+ // src/core/storage.ts
1515
+ var PersistentStore = class {
1516
+ constructor(backing, onError = () => {
1517
+ }) {
1518
+ __publicField(this, "backing", backing);
1519
+ __publicField(this, "onError", onError);
1520
+ __publicField(this, "cache", /* @__PURE__ */ new Map());
1521
+ __publicField(this, "chain", Promise.resolve());
1522
+ }
1523
+ async hydrate(keys) {
1524
+ if (!this.backing) return;
1525
+ await Promise.all(
1526
+ keys.map(async (k) => {
1527
+ try {
1528
+ const v = await this.backing.getItem(k);
1529
+ if (v != null && !this.cache.has(k)) this.cache.set(k, v);
1530
+ } catch (err) {
1531
+ this.onError(err);
1532
+ }
1533
+ })
1534
+ );
1535
+ }
1536
+ get(key) {
1537
+ return this.cache.get(key) ?? null;
1538
+ }
1539
+ set(key, value) {
1540
+ if (this.cache.get(key) === value) return;
1541
+ this.cache.set(key, value);
1542
+ this.write(() => this.backing?.setItem(key, value));
1543
+ }
1544
+ remove(key) {
1545
+ if (!this.cache.has(key)) return;
1546
+ this.cache.delete(key);
1547
+ this.write(() => this.backing?.removeItem(key));
1548
+ }
1549
+ /** Resolves once every pending write has reached the backing storage. */
1550
+ flush() {
1551
+ return this.chain.then(() => void 0);
1552
+ }
1553
+ write(op) {
1554
+ if (!this.backing) return;
1555
+ this.chain = this.chain.then(async () => {
1556
+ try {
1557
+ await op();
1558
+ } catch (err) {
1559
+ this.onError(err);
1560
+ }
1561
+ });
1562
+ }
1563
+ };
1564
+
1565
+ // src/core/client.ts
1566
+ var SDK_NAME = "@guidelytech/react-native";
1567
+ var SDK_VERSION = "0.4.1";
1568
+ var DEFAULT_API_URL = "https://guidelytech.com";
1569
+ var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
1570
+ var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
1571
+ var emptyState = () => ({ guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] });
1572
+ var newSession = () => ({ id: `s_${randomId(16)}`, startedAt: nowIso(), lastActiveAt: nowIso(), shownGuides: [], events: [] });
1573
+ var GuidelyClient = class {
1574
+ constructor(options, bridge) {
1575
+ __publicField(this, "options");
1576
+ __publicField(this, "targets", new TargetRegistry());
1577
+ __publicField(this, "bridge");
1578
+ __publicField(this, "api");
1579
+ __publicField(this, "store");
1580
+ __publicField(this, "emitter");
1581
+ __publicField(this, "queue", null);
1582
+ __publicField(this, "config", null);
1583
+ __publicField(this, "configFetchedAt", 0);
1584
+ __publicField(this, "etag", null);
1585
+ __publicField(this, "anonymousId", "");
1586
+ __publicField(this, "user", null);
1587
+ __publicField(this, "state", emptyState());
1588
+ __publicField(this, "session", newSession());
1589
+ __publicField(this, "consent");
1590
+ __publicField(this, "readyPromise", null);
1591
+ __publicField(this, "hydrated", false);
1592
+ __publicField(this, "pending", []);
1593
+ __publicField(this, "isReady", false);
1594
+ __publicField(this, "destroyed", false);
1595
+ __publicField(this, "active", null);
1596
+ __publicField(this, "activeSurvey", null);
1597
+ __publicField(this, "queued", []);
1598
+ __publicField(this, "flows", /* @__PURE__ */ new Map());
1599
+ __publicField(this, "checklists", /* @__PURE__ */ new Map());
1600
+ __publicField(this, "expanded", /* @__PURE__ */ new Set());
1601
+ __publicField(this, "surveyView", null);
1602
+ __publicField(this, "resourceCenterOpen", false);
1603
+ __publicField(this, "cleanups", []);
1604
+ __publicField(this, "timers", /* @__PURE__ */ new Set());
1605
+ __publicField(this, "visibilityWatchers", /* @__PURE__ */ new Map());
1606
+ __publicField(this, "firedDelays", /* @__PURE__ */ new Set());
1607
+ __publicField(this, "firstVisit", true);
1608
+ __publicField(this, "screenName", "");
1609
+ __publicField(this, "lastAudiences", /* @__PURE__ */ new Set());
1610
+ __publicField(this, "backgroundedAt", null);
1611
+ __publicField(this, "messenger", null);
1612
+ __publicField(this, "ui", { steps: [], checklists: [], survey: null, resourceCenterOpen: false, theme: {}, messenger: false });
1613
+ __publicField(this, "uiListeners", /* @__PURE__ */ new Set());
1614
+ __publicField(this, "customListeners", /* @__PURE__ */ new Map());
1615
+ __publicField(this, "eventBus", {
1616
+ on: (name, cb) => {
1617
+ let set = this.customListeners.get(name);
1618
+ if (!set) {
1619
+ set = /* @__PURE__ */ new Set();
1620
+ this.customListeners.set(name, set);
1621
+ }
1622
+ set.add(cb);
1623
+ return () => void set?.delete(cb);
1624
+ }
1625
+ });
1626
+ if (!options || !options.projectId) throw new Error("[Guidely] `projectId` (your environment public key, e.g. pk_dev_\u2026) is required.");
1627
+ this.options = {
1628
+ ...options,
1629
+ environment: options.environment ?? (options.projectId.startsWith("pk_prod") ? "production" : options.projectId.startsWith("pk_stag") ? "staging" : "development"),
1630
+ apiUrl: options.apiUrl ?? DEFAULT_API_URL,
1631
+ analytics: options.analytics ?? true,
1632
+ configRefreshMs: options.configRefreshMs ?? 5 * 60 * 1e3,
1633
+ debug: options.debug ?? false
1634
+ };
1635
+ this.bridge = bridge;
1636
+ this.consent = options.consent ?? "granted";
1637
+ this.emitter = new Emitter((err) => this.reportError(err));
1638
+ this.api = new Api(this.options.apiUrl, this.options.projectId, options.fetch);
1639
+ this.store = new PersistentStore(options.storage ?? null, (err) => this.log("storage error", err));
1640
+ if (!options.storage) this.log("no `storage` passed: identity and guide history last only for this app session. Pass AsyncStorage to persist them.");
1641
+ this.cleanups.push(this.targets.onTap((id) => this.handleTap(id)));
1642
+ }
1643
+ // ---------------------------------------------------------------------------
1644
+ // Lifecycle
1645
+ // ---------------------------------------------------------------------------
1646
+ /** Loads persisted state and config, identifies the stored user and evaluates triggers. Idempotent. */
1647
+ init() {
1648
+ if (!this.readyPromise) this.readyPromise = this.bootstrap();
1649
+ return this.readyPromise;
1650
+ }
1651
+ ready() {
1652
+ return this.init();
1653
+ }
1654
+ async bootstrap() {
1655
+ const key = this.options.projectId;
1656
+ try {
1657
+ await this.store.hydrate([KEYS.anon, KEYS.seen, KEYS.session, KEYS.config(key), KEYS.etag(key), KEYS.state(key), KEYS.user(key), KEYS.queue(key), KEYS.conversations(key)]);
1658
+ if (this.destroyed) return;
1659
+ this.anonymousId = this.store.get(KEYS.anon) || "";
1660
+ if (!this.anonymousId) {
1661
+ this.anonymousId = `anon_${randomId(20)}`;
1662
+ this.store.set(KEYS.anon, this.anonymousId);
1663
+ }
1664
+ this.firstVisit = !this.store.get(KEYS.seen);
1665
+ this.store.set(KEYS.seen, nowIso());
1666
+ this.state = { ...emptyState(), ...readJson(this.store, KEYS.state(key), {}) };
1667
+ this.user = this.options.user ? { ...this.options.user } : readJson(this.store, KEYS.user(key), null);
1668
+ const saved = readJson(this.store, KEYS.session, null);
1669
+ this.session = saved?.id && !this.sessionExpired(Date.parse(saved.lastActiveAt)) ? saved : newSession();
1670
+ this.persistSession();
1671
+ this.lastAudiences = new Set(this.state.audienceIds);
1672
+ this.queue = new EventQueue({
1673
+ store: this.store,
1674
+ storageKey: KEYS.queue(key),
1675
+ enabled: () => this.analyticsEnabled(),
1676
+ send: async (events) => {
1677
+ const res = await this.api.post("/events", { events, sentAt: nowIso(), sdk: { name: SDK_NAME, version: SDK_VERSION } });
1678
+ return { ok: res.ok, retry: !(res.status >= 400 && res.status < 500 && res.status !== 429) };
1679
+ },
1680
+ onError: (err) => this.reportError(err)
1681
+ });
1682
+ this.hydrated = true;
1683
+ const cached = readJson(this.store, KEYS.config(key), null);
1684
+ this.etag = this.store.get(KEYS.etag(key));
1685
+ if (cached && Array.isArray(cached.guides)) this.config = forPlatform(cached, this.bridge.device.platform);
1686
+ this.syncMessenger();
1687
+ this.pending.splice(0).forEach((fn) => fn());
1688
+ await this.fetchConfig();
1689
+ if (this.destroyed) return;
1690
+ this.wireTimers();
1691
+ if (this.user?.id) await this.identify(this.user.id, this.user.traits, this.user.account);
1692
+ if (this.destroyed) return;
1693
+ this.isReady = true;
1694
+ if (this.config) this.emitter.emit("ready", this.config);
1695
+ this.notify();
1696
+ this.evaluate("ready");
1697
+ } catch (err) {
1698
+ this.reportError(err);
1699
+ this.isReady = true;
1700
+ }
1701
+ }
1702
+ whenHydrated(fn) {
1703
+ if (this.destroyed) return;
1704
+ if (this.hydrated) fn();
1705
+ else this.pending.push(fn);
1706
+ }
1707
+ async fetchConfig() {
1708
+ try {
1709
+ const res = await this.api.get("/config", { etag: this.etag, params: { platform: this.bridge.device.platform } });
1710
+ this.configFetchedAt = Date.now();
1711
+ if (res.status === 304) return true;
1712
+ if (res.ok && res.data && Array.isArray(res.data.guides)) {
1713
+ this.config = forPlatform(res.data, this.bridge.device.platform);
1714
+ this.etag = res.etag;
1715
+ writeJson(this.store, KEYS.config(this.options.projectId), res.data);
1716
+ if (res.etag) this.store.set(KEYS.etag(this.options.projectId), res.etag);
1717
+ else this.store.remove(KEYS.etag(this.options.projectId));
1718
+ this.syncMessenger();
1719
+ this.emitter.emit("config", this.config);
1720
+ this.log("config loaded", this.config.configVersion, `${this.config.guides.length} guides`);
1721
+ return true;
1722
+ }
1723
+ this.reportError(new Error(`[Guidely] config request failed (${res.status})${this.config ? "; using cached config" : ""}`));
1724
+ return false;
1725
+ } catch (err) {
1726
+ this.reportError(new Error(`[Guidely] config request failed: ${err instanceof Error ? err.message : String(err)}${this.config ? "; using cached config" : ""}`));
1727
+ return false;
1728
+ }
1729
+ }
1730
+ wireTimers() {
1731
+ const refresh = setInterval(() => void this.refresh(), this.options.configRefreshMs);
1732
+ this.cleanups.push(() => clearInterval(refresh));
1733
+ const poll = setInterval(() => {
1734
+ if (!this.isReady || !this.config || this.backgroundedAt !== null) return;
1735
+ const url = this.currentUrl();
1736
+ const ctx = this.getContext();
1737
+ for (const guide of this.config.guides) if (guide.config.trigger.type === "element_visible" && guide.config.trigger.value) this.armVisibility(guide, ctx, url);
1738
+ this.drainQueue(url, ctx);
1739
+ }, 1e3);
1740
+ this.cleanups.push(() => clearInterval(poll));
1741
+ }
1742
+ /** Re-fetches the published config and re-evaluates triggers. */
1743
+ async refresh() {
1744
+ if (this.destroyed) return;
1745
+ const before = this.config?.configVersion;
1746
+ await this.fetchConfig();
1747
+ if (this.config && this.config.configVersion !== before) {
1748
+ this.notify();
1749
+ this.evaluate("ready");
1750
+ }
1751
+ }
1752
+ /** Called by the provider with React Native `AppState` changes. */
1753
+ setAppState(state) {
1754
+ if (this.destroyed || !this.hydrated) return;
1755
+ if (state === "background") {
1756
+ this.backgroundedAt = Date.now();
1757
+ this.session.lastActiveAt = nowIso();
1758
+ this.persistSession();
1759
+ void this.queue?.flush();
1760
+ void this.store.flush();
1761
+ this.messenger?.setForeground(false);
1762
+ } else if (state === "active" && this.backgroundedAt !== null) {
1763
+ this.messenger?.setForeground(true);
1764
+ const away = this.backgroundedAt;
1765
+ this.backgroundedAt = null;
1766
+ if (this.sessionExpired(away)) {
1767
+ this.session = newSession();
1768
+ this.persistSession();
1769
+ this.firedDelays.clear();
1770
+ }
1771
+ if (Date.now() - this.configFetchedAt > this.options.configRefreshMs) void this.refresh();
1772
+ else if (this.isReady) this.evaluate("ready");
1773
+ }
1774
+ }
1775
+ sessionExpired(lastActiveMs) {
1776
+ const minutes = this.config?.settings?.sessionTimeoutMinutes ?? 30;
1777
+ return !Number.isFinite(lastActiveMs) || Date.now() - lastActiveMs > minutes * 6e4;
1778
+ }
1779
+ /** Tears everything down. Create a new client to start again. */
1780
+ destroy() {
1781
+ if (this.destroyed) return;
1782
+ this.destroyed = true;
1783
+ this.active?.controller.destroy();
1784
+ this.active = null;
1785
+ this.flows.forEach((f) => f.controller.destroy());
1786
+ this.flows.clear();
1787
+ this.queued = [];
1788
+ this.messenger?.destroy();
1789
+ this.messenger = null;
1790
+ this.visibilityWatchers.forEach((stop) => stop());
1791
+ this.visibilityWatchers.clear();
1792
+ this.timers.forEach((t) => clearTimeout(t));
1793
+ this.timers.clear();
1794
+ this.cleanups.splice(0).forEach((fn) => fn());
1795
+ void this.queue?.flush();
1796
+ this.queue?.destroy();
1797
+ this.emitter.clear();
1798
+ this.uiListeners.clear();
1799
+ }
1800
+ // ---------------------------------------------------------------------------
1801
+ // Identity
1802
+ // ---------------------------------------------------------------------------
1803
+ /** Identifies the end user and loads server-held state (audiences, guide history, checklist progress). */
1804
+ identify(userId, traits, account) {
1805
+ return new Promise(
1806
+ (resolve) => this.whenHydrated(() => {
1807
+ const id = String(userId);
1808
+ const same = this.user?.id === id;
1809
+ this.user = { id, traits: { ...same ? this.user?.traits : {}, ...traits || {} }, account: account ?? (same ? this.user?.account : void 0) };
1810
+ writeJson(this.store, KEYS.user(this.options.projectId), this.user);
1811
+ this.enqueue("identify", { properties: { traits: sanitizeProperties(this.user.traits) } });
1812
+ void this.syncIdentify().then(() => {
1813
+ this.emitter.emit("identify", { userId: id, anonymousId: this.anonymousId });
1814
+ if (this.isReady) this.evaluate("identify");
1815
+ resolve();
1816
+ });
1817
+ })
1818
+ );
1819
+ }
1820
+ async syncIdentify() {
1821
+ const body = { anonymousId: this.anonymousId, userId: this.user?.id || null, traits: this.user?.traits, account: this.user?.account ? { id: this.user.account.id, traits: this.user.account.traits } : null, sessionId: this.session.id, url: this.collectUrls() ? this.currentUrl() : void 0 };
1822
+ const res = await postWithRetry(this.api, "/identify", body, 2);
1823
+ if (res?.ok && res.data) this.mergeServerState(res.data);
1824
+ }
1825
+ mergeServerState(data) {
1826
+ const guides = { ...this.state.guides };
1827
+ for (const [id, s] of Object.entries(data.guideState || {})) {
1828
+ const local = guides[id];
1829
+ guides[id] = {
1830
+ impressions: Math.max(local?.impressions ?? 0, s.impressions ?? 0),
1831
+ completedAt: local?.completedAt ?? s.completedAt ?? null,
1832
+ dismissedAt: local?.dismissedAt ?? s.dismissedAt ?? null,
1833
+ lastShownAt: local?.lastShownAt ?? s.lastShownAt ?? null
1834
+ };
1835
+ }
1836
+ const checklists = { ...this.state.checklists };
1837
+ for (const [id, p] of Object.entries(data.checklistProgress || {})) {
1838
+ const local = checklists[id];
1839
+ checklists[id] = {
1840
+ completedTaskIds: Array.from(/* @__PURE__ */ new Set([...local?.completedTaskIds ?? [], ...p.completedTaskIds ?? []])),
1841
+ completedAt: local?.completedAt ?? p.completedAt ?? null,
1842
+ dismissedAt: local?.dismissedAt ?? p.dismissedAt ?? null
1843
+ };
1844
+ }
1845
+ this.state = {
1846
+ guides,
1847
+ checklists,
1848
+ surveysSubmitted: Array.from(/* @__PURE__ */ new Set([...this.state.surveysSubmitted, ...data.surveysSubmitted || []])),
1849
+ audienceIds: data.audienceIds || [],
1850
+ firstSeenAt: this.state.firstSeenAt ?? data.firstSeenAt
1851
+ };
1852
+ if (data.firstSeenAt && Date.now() - Date.parse(data.firstSeenAt) > 6e4) this.firstVisit = false;
1853
+ this.persistState();
1854
+ this.notify();
1855
+ }
1856
+ /** Sets or replaces the account (company / organisation) for the current user. */
1857
+ async setAccount(account) {
1858
+ await this.init();
1859
+ if (!this.user?.id) {
1860
+ this.user = { id: "", traits: this.user?.traits, account };
1861
+ writeJson(this.store, KEYS.user(this.options.projectId), this.user);
1862
+ return;
1863
+ }
1864
+ await this.identify(this.user.id, void 0, account);
1865
+ }
1866
+ /** Merges additional traits into the current user. */
1867
+ async updateTraits(traits) {
1868
+ await this.init();
1869
+ if (!this.user?.id) {
1870
+ this.user = { id: "", traits: { ...this.user?.traits || {}, ...traits }, account: this.user?.account };
1871
+ return;
1872
+ }
1873
+ await this.identify(this.user.id, traits, this.user.account);
1874
+ }
1875
+ /** Forgets the user (call on logout): new anonymous id, cleared guide/checklist state, new session. */
1876
+ reset() {
1877
+ this.whenHydrated(() => {
1878
+ this.stopGuide();
1879
+ this.flows.forEach((f) => f.controller.destroy());
1880
+ this.flows.clear();
1881
+ this.checklists.clear();
1882
+ this.surveyView = null;
1883
+ this.activeSurvey = null;
1884
+ this.resourceCenterOpen = false;
1885
+ if (this.messenger) this.messenger.forgetAll();
1886
+ else this.store.remove(KEYS.conversations(this.options.projectId));
1887
+ this.user = null;
1888
+ this.store.remove(KEYS.user(this.options.projectId));
1889
+ this.anonymousId = `anon_${randomId(20)}`;
1890
+ this.store.set(KEYS.anon, this.anonymousId);
1891
+ this.state = emptyState();
1892
+ this.persistState();
1893
+ this.session = newSession();
1894
+ this.persistSession();
1895
+ this.lastAudiences = /* @__PURE__ */ new Set();
1896
+ this.firedDelays.clear();
1897
+ this.firstVisit = true;
1898
+ this.emitter.emit("identify", { userId: null, anonymousId: this.anonymousId });
1899
+ this.notify();
1900
+ if (this.isReady) this.evaluate("identify");
1901
+ });
1902
+ }
1903
+ // ---------------------------------------------------------------------------
1904
+ // Events & screens
1905
+ // ---------------------------------------------------------------------------
1906
+ /** Updates consent at runtime (`granted` flushes buffered events; `denied` drops them). */
1907
+ setConsent(consent) {
1908
+ this.consent = consent;
1909
+ this.whenHydrated(() => {
1910
+ if (consent === "denied") this.queue?.clear();
1911
+ else if (consent === "granted") void this.queue?.flush();
1912
+ });
1913
+ }
1914
+ analyticsEnabled() {
1915
+ return this.options.analytics && this.consent === "granted" && (this.config?.settings?.analyticsEnabled ?? true);
1916
+ }
1917
+ collectUrls() {
1918
+ return this.config?.settings?.collectUrls ?? true;
1919
+ }
1920
+ currentUrl() {
1921
+ return screenPath(this.screenName);
1922
+ }
1923
+ enqueue(name, extra = {}) {
1924
+ if (this.consent === "denied" || !this.options.analytics || !this.queue) return null;
1925
+ const event = {
1926
+ eventId: randomId(24),
1927
+ name,
1928
+ occurredAt: nowIso(),
1929
+ anonymousId: this.anonymousId,
1930
+ userId: this.user?.id || null,
1931
+ accountId: this.user?.account?.id || null,
1932
+ sessionId: this.session.id,
1933
+ url: this.collectUrls() && this.screenName ? this.currentUrl() : void 0,
1934
+ ...extra
1935
+ };
1936
+ if (extra.properties) event.properties = sanitizeProperties(extra.properties);
1937
+ this.queue.enqueue(event);
1938
+ this.emitter.emit("track", event);
1939
+ return event;
1940
+ }
1941
+ /** Tracks a custom event. Events drive `event` triggers, `advanceOn: "event"` steps and checklist tasks. */
1942
+ track(name, properties) {
1943
+ if (!name) return;
1944
+ this.whenHydrated(() => {
1945
+ if (!this.session.events.includes(name)) {
1946
+ this.session.events.push(name);
1947
+ this.persistSession();
1948
+ }
1949
+ this.enqueue("custom", { customName: name.slice(0, 120), properties: properties ?? {} });
1950
+ this.customListeners.get(name)?.forEach((cb) => {
1951
+ try {
1952
+ cb();
1953
+ } catch (err) {
1954
+ this.reportError(err);
1955
+ }
1956
+ });
1957
+ this.completeChecklistTasksBy("event", name);
1958
+ if (this.isReady) this.evaluate("event", { eventName: name });
1959
+ });
1960
+ }
1961
+ /**
1962
+ * Records a screen view (the mobile equivalent of a page view) and re-evaluates triggers. URL
1963
+ * patterns, `url_match` triggers, multi-screen steps and `url_visited` tasks match `/<name>`.
1964
+ */
1965
+ screen(name, properties) {
1966
+ if (!name) return;
1967
+ this.whenHydrated(() => {
1968
+ const changed = name !== this.screenName;
1969
+ this.screenName = name;
1970
+ this.targets.setScreen(name);
1971
+ const url = this.currentUrl();
1972
+ if (changed) this.enqueue("page_view", { properties: { ...properties, path: url, screen: name } });
1973
+ this.completeChecklistTasksBy("url_visited", url);
1974
+ this.active?.controller.resume();
1975
+ this.flows.forEach((f) => f.controller.resume());
1976
+ if (this.active) {
1977
+ const st = this.active.controller.getState();
1978
+ const g = this.active.guide;
1979
+ if (st.status === "running" && !this.active.manual && g.config.urlPatterns.length && !g.config.urlPatterns.some((p) => screenMatches(p, url))) this.active.controller.destroy();
1980
+ }
1981
+ this.flows.forEach((f) => {
1982
+ const patterns = f.guide.config.urlPatterns;
1983
+ if (!f.manual && patterns.length && !patterns.some((p) => screenMatches(p, url))) f.controller.destroy();
1984
+ });
1985
+ this.firedDelays.clear();
1986
+ if (this.isReady) this.evaluate("navigation");
1987
+ if (changed) void this.queue?.flush();
1988
+ });
1989
+ }
1990
+ /** The current screen name (last value passed to `screen()`). */
1991
+ getScreen() {
1992
+ return this.screenName;
1993
+ }
1994
+ handleTap(targetId) {
1995
+ if (!this.hydrated) return;
1996
+ this.completeChecklistTasksBy("click", targetId);
1997
+ if (this.isReady) this.evaluate("click", { targetId });
1998
+ }
1999
+ // ---------------------------------------------------------------------------
2000
+ // Targeting + triggers
2001
+ // ---------------------------------------------------------------------------
2002
+ /** Builds the targeting context for the current moment. */
2003
+ getContext() {
2004
+ const guides = this.state.guides;
2005
+ const keysOf = (ids) => ids.flatMap((id) => [id, this.findGuide(id)?.key ?? id]);
2006
+ const url = this.currentUrl();
2007
+ const device = this.bridge.device;
2008
+ return {
2009
+ anonymousId: this.anonymousId,
2010
+ userId: this.user?.id || null,
2011
+ userAttributes: this.user?.traits ?? {},
2012
+ accountAttributes: this.user?.account?.traits ?? {},
2013
+ url,
2014
+ path: url,
2015
+ query: {},
2016
+ device: { type: device.deviceType, os: device.platform },
2017
+ firstVisit: this.firstVisit,
2018
+ eventsSeen: new Set(this.session.events),
2019
+ completedGuides: new Set(keysOf(Object.keys(guides).filter((id) => guides[id]?.completedAt))),
2020
+ dismissedGuides: new Set(keysOf(Object.keys(guides).filter((id) => guides[id]?.dismissedAt))),
2021
+ seenGuides: new Set(keysOf(Object.keys(guides).filter((id) => (guides[id]?.impressions ?? 0) > 0))),
2022
+ completedChecklists: new Set(keysOf(Object.entries(this.state.checklists).filter(([, p]) => !!p.completedAt).map(([id]) => id))),
2023
+ audienceIds: new Set(this.state.audienceIds),
2024
+ now: /* @__PURE__ */ new Date(),
2025
+ environment: this.options.environment,
2026
+ locale: device.locale,
2027
+ platform: device.platform,
2028
+ screen: this.screenName,
2029
+ appVersion: this.options.appVersion ?? device.appVersion
2030
+ };
2031
+ }
2032
+ isTargeted(guide, ctx, url) {
2033
+ const c = guide.config;
2034
+ if (c.urlPatterns.length && !c.urlPatterns.some((p) => screenMatches(p, url))) return false;
2035
+ if (c.trigger.urlPattern && !screenMatches(c.trigger.urlPattern, url)) return false;
2036
+ if (!isWithinSchedule(guide)) return false;
2037
+ if (guide.kind === "survey" && (this.state.surveysSubmitted.includes(guide.id) || this.state.surveysSubmitted.includes(guide.key))) return false;
2038
+ ctx.rolloutSalt = guide.id;
2039
+ return evaluateRules(c.targeting, ctx);
2040
+ }
2041
+ isEligible(guide, ctx, url) {
2042
+ if (!this.isTargeted(guide, ctx, url)) return false;
2043
+ if (guide.kind === "checklist") return !this.state.checklists[guide.id]?.dismissedAt;
2044
+ return isFrequencyEligible(guide.config.frequency, this.state.guides[guide.id], this.session.shownGuides.includes(guide.id));
2045
+ }
2046
+ triggerFires(guide, reason, detail) {
2047
+ const t = guide.config.trigger;
2048
+ switch (t.type) {
2049
+ case "page_load":
2050
+ if (t.delayMs > 0) return reason === "timer" && detail.guideId === guide.id;
2051
+ return reason === "ready" || reason === "navigation" || reason === "identify";
2052
+ case "url_match":
2053
+ return (reason === "ready" || reason === "navigation" || reason === "identify") && screenMatches(t.value, this.currentUrl());
2054
+ case "event":
2055
+ return reason === "event" && !!t.value && detail.eventName === t.value;
2056
+ case "element_click":
2057
+ return reason === "click" && !!detail.targetId && guidelyIdFromSelector(t.value) === detail.targetId;
2058
+ case "element_visible":
2059
+ return reason === "visible" && detail.guideId === guide.id;
2060
+ case "delay":
2061
+ return reason === "timer" && detail.guideId === guide.id;
2062
+ case "after_guide":
2063
+ return reason === "after_guide" && !!detail.endedGuide && (detail.endedGuide.id === t.value || detail.endedGuide.key === t.value);
2064
+ case "segment_enter":
2065
+ return reason === "identify" && !!t.value && !!detail.newAudiences?.has(t.value);
2066
+ case "manual":
2067
+ default:
2068
+ return false;
2069
+ }
2070
+ }
2071
+ evaluate(reason, detail = {}) {
2072
+ if (!this.config || this.destroyed) return;
2073
+ const url = this.currentUrl();
2074
+ const ctx = this.getContext();
2075
+ if (reason === "identify") {
2076
+ const current = new Set(this.state.audienceIds);
2077
+ detail.newAudiences = new Set(Array.from(current).filter((a) => !this.lastAudiences.has(a)));
2078
+ this.lastAudiences = current;
2079
+ }
2080
+ const candidates = [];
2081
+ for (const guide of this.config.guides) {
2082
+ const t = guide.config.trigger;
2083
+ if (detail.endedGuide && detail.endedGuide.id === guide.id) continue;
2084
+ if (reason === "ready" || reason === "navigation") {
2085
+ if (t.type === "element_visible" && t.value) this.armVisibility(guide, ctx, url);
2086
+ if ((t.type === "delay" || t.type === "page_load" && t.delayMs > 0) && !this.firedDelays.has(guide.id)) {
2087
+ if (!this.isEligible(guide, ctx, url)) continue;
2088
+ this.firedDelays.add(guide.id);
2089
+ this.setTimer(() => this.evaluate("timer", { guideId: guide.id }), t.delayMs);
2090
+ continue;
2091
+ }
2092
+ }
2093
+ if (t.type === "page_load" && t.delayMs > 0 && reason !== "timer") continue;
2094
+ if (t.type === "page_load" && t.delayMs > 0 && reason === "timer" && detail.guideId !== guide.id) continue;
2095
+ if (!this.triggerFires(guide, reason, detail)) continue;
2096
+ if (!this.isEligible(guide, ctx, url)) continue;
2097
+ candidates.push(guide);
2098
+ }
2099
+ if (reason === "after_guide" || reason === "navigation") this.drainQueue(url, ctx);
2100
+ if (!candidates.length) return;
2101
+ candidates.sort((a, b) => a.config.frequency.priority - b.config.frequency.priority);
2102
+ for (const guide of candidates) {
2103
+ if (this.isShowing(guide.id)) continue;
2104
+ const delay = guide.config.trigger.type !== "delay" && guide.config.trigger.type !== "page_load" ? guide.config.trigger.delayMs : 0;
2105
+ if (delay > 0) this.setTimer(() => this.tryShow(guide, url, false), delay);
2106
+ else this.tryShow(guide, url, false);
2107
+ }
2108
+ }
2109
+ armVisibility(guide, ctx, url) {
2110
+ const id = guidelyIdFromSelector(guide.config.trigger.value);
2111
+ if (!id || this.visibilityWatchers.has(guide.id) || !this.isEligible(guide, ctx, url)) return;
2112
+ const ac = new AbortController();
2113
+ const stop = () => {
2114
+ ac.abort();
2115
+ this.visibilityWatchers.delete(guide.id);
2116
+ };
2117
+ this.visibilityWatchers.set(guide.id, stop);
2118
+ this.targets.waitFor([id], { timeoutMs: 0, signal: ac.signal }).then(
2119
+ () => {
2120
+ if (ac.signal.aborted) return;
2121
+ stop();
2122
+ this.evaluate("visible", { guideId: guide.id });
2123
+ },
2124
+ () => stop()
2125
+ );
2126
+ }
2127
+ setTimer(fn, ms) {
2128
+ const t = setTimeout(() => {
2129
+ this.timers.delete(t);
2130
+ if (!this.destroyed) fn();
2131
+ }, ms);
2132
+ this.timers.add(t);
2133
+ }
2134
+ isShowing(id) {
2135
+ return this.flows.has(id) || this.checklists.has(id) || this.active?.guide.id === id || this.activeSurvey?.guide.id === id;
2136
+ }
2137
+ hasExclusive() {
2138
+ return this.active?.guide ?? this.activeSurvey?.guide ?? null;
2139
+ }
2140
+ tryShow(guide, url, manual) {
2141
+ if (this.destroyed) return false;
2142
+ if (!isExclusiveGuide(guide)) {
2143
+ if (this.flows.has(guide.id) || this.checklists.has(guide.id)) return false;
2144
+ this.show(guide, manual);
2145
+ return true;
2146
+ }
2147
+ const current = this.hasExclusive();
2148
+ if (current) {
2149
+ if (current.id === guide.id) return false;
2150
+ if (manual) this.stopGuide();
2151
+ else {
2152
+ if (!this.queued.some((q) => q.guide.id === guide.id)) this.queued.push({ guide, url, pageScoped: PAGE_SCOPED.has(guide.config.trigger.type) });
2153
+ this.log("queued", guide.key, "behind", current.key);
2154
+ return false;
2155
+ }
2156
+ }
2157
+ const blocked = [...this.flows.values()].map((f) => f.guide).some((g) => areMutuallyExclusive(g, guide));
2158
+ if (blocked && !manual) {
2159
+ if (!this.queued.some((q) => q.guide.id === guide.id)) this.queued.push({ guide, url, pageScoped: true });
2160
+ return false;
2161
+ }
2162
+ this.show(guide, manual);
2163
+ return true;
2164
+ }
2165
+ drainQueue(url, ctx) {
2166
+ if (this.hasExclusive()) return;
2167
+ this.queued = this.queued.filter((q) => !(q.pageScoped && q.url !== url));
2168
+ while (this.queued.length) {
2169
+ const next = this.queued.shift();
2170
+ if (!this.isEligible(next.guide, ctx, url)) continue;
2171
+ if (this.tryShow(next.guide, url, false)) return;
2172
+ }
2173
+ }
2174
+ // ---------------------------------------------------------------------------
2175
+ // Showing guides
2176
+ // ---------------------------------------------------------------------------
2177
+ findGuide(idOrKey) {
2178
+ return this.config?.guides.find((g) => g.id === idOrKey || g.key === idOrKey);
2179
+ }
2180
+ guideState(id) {
2181
+ return this.state.guides[id] ?? (this.state.guides[id] = { impressions: 0 });
2182
+ }
2183
+ recordImpression(guide) {
2184
+ const s = this.guideState(guide.id);
2185
+ s.impressions += 1;
2186
+ s.lastShownAt = nowIso();
2187
+ if (!this.session.shownGuides.includes(guide.id)) this.session.shownGuides.push(guide.id);
2188
+ this.persistState();
2189
+ this.persistSession();
2190
+ this.syncGuideState(guide.id, { impression: true });
2191
+ }
2192
+ recordEnd(guide, reason) {
2193
+ const s = this.guideState(guide.id);
2194
+ if (reason === "completed") s.completedAt = nowIso();
2195
+ else s.dismissedAt = nowIso();
2196
+ this.persistState();
2197
+ this.syncGuideState(guide.id, reason === "completed" ? { completed: true } : { dismissed: true });
2198
+ if (reason === "completed") this.completeChecklistTasksBy("guide_completed", guide);
2199
+ }
2200
+ syncGuideState(guideId, update) {
2201
+ void postWithRetry(this.api, "/guide-state", { anonymousId: this.anonymousId, userId: this.user?.id || null, updates: [{ guideId, ...update }] });
2202
+ }
2203
+ show(guide, manual, stepId) {
2204
+ try {
2205
+ if (guide.kind === "checklist" && guide.config.checklist) return this.showChecklist(guide);
2206
+ if (guide.kind === "survey" && guide.config.survey) return this.showSurvey(guide, manual);
2207
+ this.showFlow(guide, manual, stepId);
2208
+ } catch (err) {
2209
+ this.reportError(err);
2210
+ }
2211
+ }
2212
+ showFlow(guide, manual, stepId) {
2213
+ const exclusive = isExclusiveGuide(guide);
2214
+ const flow = { guide, manual, view: null, controller: null };
2215
+ let renders = 0;
2216
+ flow.controller = runGuide(guide, {
2217
+ targets: this.targets,
2218
+ ctx: () => this.getContext(),
2219
+ currentUrl: () => this.currentUrl(),
2220
+ startAtStep: stepId,
2221
+ eventBus: this.eventBus,
2222
+ onError: (err) => this.reportError(err),
2223
+ onEvent: (e) => {
2224
+ const step = e.stepId ? guide.config.steps.find((s) => s.id === e.stepId) : void 0;
2225
+ this.enqueue(e.name, { guideId: guide.id, guideVersion: guide.version, stepId: e.stepId, stepIndex: e.stepIndex, properties: e.properties });
2226
+ if (e.name === "guide_started") {
2227
+ this.recordImpression(guide);
2228
+ this.emitter.emit("guide:start", guide);
2229
+ } else if (e.name === "step_viewed" && step) {
2230
+ this.emitter.emit("guide:step", { guide, stepId: step.id, stepIndex: e.stepIndex ?? 0 });
2231
+ } else if (e.name === "guide_completed") {
2232
+ this.recordEnd(guide, "completed");
2233
+ this.emitter.emit("guide:complete", guide);
2234
+ } else if (e.name === "guide_dismissed") {
2235
+ this.recordEnd(guide, "dismissed");
2236
+ this.emitter.emit("guide:dismiss", guide);
2237
+ } else if (e.name === "guide_skipped") {
2238
+ this.recordEnd(guide, "skipped");
2239
+ this.emitter.emit("guide:skip", guide);
2240
+ }
2241
+ },
2242
+ onRender: (r) => {
2243
+ renders += 1;
2244
+ this.expanded.delete(guide.id);
2245
+ flow.view = { key: `${guide.id}:${r.step.id}:${renders}`, guide, step: r.step, index: r.index, total: r.total, canGoBack: r.canGoBack, targetId: r.targetId, asModal: r.asModal, expanded: false };
2246
+ this.notify();
2247
+ },
2248
+ onClear: () => {
2249
+ flow.view = null;
2250
+ this.notify();
2251
+ },
2252
+ onNavigate: (url) => this.navigate(url),
2253
+ onAction: (action, value) => {
2254
+ if (action === "start_guide" && value) this.setTimer(() => this.startGuide(value), 0);
2255
+ else if (action === "event" && value) this.track(value);
2256
+ else if (action === "open_resource_center") this.openResourceCenter();
2257
+ },
2258
+ onEnd: (reason) => {
2259
+ if (exclusive) {
2260
+ if (this.active === flow) this.active = null;
2261
+ } else if (this.flows.get(guide.id) === flow) this.flows.delete(guide.id);
2262
+ this.notify();
2263
+ if (reason !== "destroyed" && !this.destroyed) this.setTimer(() => this.evaluate("after_guide", { endedGuide: guide }), 0);
2264
+ }
2265
+ });
2266
+ if (exclusive) this.active = flow;
2267
+ else this.flows.set(guide.id, flow);
2268
+ flow.controller.start();
2269
+ if (flow.controller.getState().status === "ended") {
2270
+ if (this.active === flow) this.active = null;
2271
+ if (this.flows.get(guide.id) === flow) this.flows.delete(guide.id);
2272
+ }
2273
+ }
2274
+ showChecklist(guide, open) {
2275
+ const config = guide.config.checklist;
2276
+ if (!config || this.checklists.has(guide.id)) return;
2277
+ const progress = this.getChecklistProgress(guide.id);
2278
+ const first = !this.state.guides[guide.id]?.impressions;
2279
+ this.checklists.set(guide.id, { open: open ?? (config.autoOpen && first && !progress.completedAt) });
2280
+ if (first) this.recordImpression(guide);
2281
+ if (this.checklists.get(guide.id)?.open) this.enqueue("guide_viewed", { guideId: guide.id, guideVersion: guide.version });
2282
+ this.notify();
2283
+ }
2284
+ showSurvey(guide, manual) {
2285
+ if (!guide.config.survey) return;
2286
+ this.activeSurvey = { guide, manual };
2287
+ this.surveyView = { key: `${guide.id}:${randomId(6)}`, guide, submitted: false };
2288
+ this.recordImpression(guide);
2289
+ this.enqueue("survey_displayed", { guideId: guide.id, guideVersion: guide.version });
2290
+ this.emitter.emit("guide:start", guide);
2291
+ this.notify();
2292
+ }
2293
+ finishSurvey(reason) {
2294
+ const s = this.activeSurvey;
2295
+ if (!s) return;
2296
+ this.activeSurvey = null;
2297
+ this.surveyView = null;
2298
+ if (reason === "dismissed") {
2299
+ this.enqueue("survey_dismissed", { guideId: s.guide.id, guideVersion: s.guide.version });
2300
+ this.recordEnd(s.guide, "dismissed");
2301
+ this.emitter.emit("guide:dismiss", s.guide);
2302
+ }
2303
+ this.notify();
2304
+ this.setTimer(() => this.evaluate("after_guide", { endedGuide: s.guide }), 0);
2305
+ }
2306
+ /** Theme for a guide: project theme ← app overrides ← guide appearance. */
2307
+ themeFor(guide) {
2308
+ const a = guide?.config.appearance;
2309
+ return {
2310
+ ...this.config?.theme || {},
2311
+ ...this.options.theme || {},
2312
+ ...a?.primaryColor ? { primaryColor: a.primaryColor } : {},
2313
+ ...a?.backgroundColor ? { backgroundColor: a.backgroundColor } : {},
2314
+ ...a?.textColor ? { textColor: a.textColor } : {},
2315
+ ...a?.borderRadius != null ? { borderRadius: a.borderRadius } : {},
2316
+ dark: a?.theme ?? "auto",
2317
+ overlayOpacity: a?.overlayOpacity
2318
+ };
2319
+ }
2320
+ /**
2321
+ * Opens a URL from a button, link or task: the app's `onNavigate` handler first (deep links and
2322
+ * in-app routes), then http(s) and custom-scheme URLs through the OS.
2323
+ */
2324
+ navigate(url) {
2325
+ if (!url) return;
2326
+ try {
2327
+ if (this.options.onNavigate?.(url) === true) return;
2328
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) this.bridge.openExternal(url);
2329
+ else if (!this.options.onNavigate) this.log(`no onNavigate handler for in-app URL "${url}"`);
2330
+ } catch (err) {
2331
+ this.reportError(err);
2332
+ }
2333
+ }
2334
+ // ---------------------------------------------------------------------------
2335
+ // UI state (consumed by the overlay)
2336
+ // ---------------------------------------------------------------------------
2337
+ getUiState() {
2338
+ return this.ui;
2339
+ }
2340
+ subscribeUi(cb) {
2341
+ this.uiListeners.add(cb);
2342
+ return () => this.uiListeners.delete(cb);
2343
+ }
2344
+ notify() {
2345
+ const steps = [];
2346
+ for (const f of [...this.flows.values(), ...this.active ? [this.active] : []]) if (f.view) steps.push({ ...f.view, expanded: this.expanded.has(f.guide.id) });
2347
+ const checklists = [];
2348
+ this.checklists.forEach((w, id) => {
2349
+ const guide = this.findGuide(id);
2350
+ if (guide) checklists.push({ guide, progress: this.getChecklistProgress(id), open: w.open });
2351
+ });
2352
+ this.ui = { steps, checklists, survey: this.surveyView, resourceCenterOpen: this.resourceCenterOpen, theme: this.themeFor(), messenger: !!this.messenger };
2353
+ for (const cb of Array.from(this.uiListeners)) {
2354
+ try {
2355
+ cb();
2356
+ } catch (err) {
2357
+ this.reportError(err);
2358
+ }
2359
+ }
2360
+ }
2361
+ flowFor(guideId) {
2362
+ return this.active?.guide.id === guideId ? this.active : this.flows.get(guideId);
2363
+ }
2364
+ /** A step button was pressed in the overlay. */
2365
+ pressButton(guideId, button) {
2366
+ this.flowFor(guideId)?.controller.press(button);
2367
+ }
2368
+ /** The close (×) button of a step. */
2369
+ closeStep(guideId) {
2370
+ this.flowFor(guideId)?.controller.dismiss();
2371
+ }
2372
+ /** The dimmed backdrop behind a step was tapped. */
2373
+ backdropPressed(guideId) {
2374
+ const f = this.flowFor(guideId);
2375
+ if (f?.view?.step.dismissOnOutsideClick) f.controller.dismiss();
2376
+ }
2377
+ /** Hotspot dot tapped: show (or hide) its card. */
2378
+ toggleHotspot(guideId) {
2379
+ if (this.expanded.has(guideId)) this.expanded.delete(guideId);
2380
+ else this.expanded.add(guideId);
2381
+ this.notify();
2382
+ }
2383
+ setChecklistOpen(guideId, open) {
2384
+ const w = this.checklists.get(guideId);
2385
+ const guide = this.findGuide(guideId);
2386
+ if (!w || !guide || w.open === open) return;
2387
+ w.open = open;
2388
+ if (open) this.enqueue("guide_viewed", { guideId: guide.id, guideVersion: guide.version });
2389
+ this.notify();
2390
+ }
2391
+ checklistTaskPressed(guideId, taskId) {
2392
+ const guide = this.findGuide(guideId);
2393
+ const task = guide?.config.checklist?.tasks.find((t) => t.id === taskId);
2394
+ if (!guide || !task) return;
2395
+ if (task.completion === "manual") this.markTaskDone(guide, task);
2396
+ if (task.action === "start_guide" && task.actionValue) {
2397
+ this.setChecklistOpen(guide.id, false);
2398
+ this.startGuide(task.actionValue);
2399
+ } else if (task.action === "url" && task.actionValue) this.navigate(task.actionValue);
2400
+ else if (task.action === "event" && task.actionValue) this.track(task.actionValue);
2401
+ }
2402
+ dismissChecklist(guideId) {
2403
+ const guide = this.findGuide(guideId);
2404
+ if (!guide) return;
2405
+ const progress = this.state.checklists[guide.id] ?? (this.state.checklists[guide.id] = { completedTaskIds: [] });
2406
+ progress.dismissedAt = nowIso();
2407
+ this.persistState();
2408
+ this.enqueue("checklist_dismissed", { guideId: guide.id, guideVersion: guide.version });
2409
+ void postWithRetry(this.api, `/checklists/${encodeURIComponent(guide.id)}/progress`, { anonymousId: this.anonymousId, userId: this.user?.id || null, guideVersion: guide.version, completedTaskIds: progress.completedTaskIds, dismissed: true });
2410
+ this.checklists.delete(guide.id);
2411
+ this.notify();
2412
+ }
2413
+ submitSurvey(guideId, answers) {
2414
+ const s = this.activeSurvey;
2415
+ if (!s || s.guide.id !== guideId || !this.surveyView || this.surveyView.submitted) return;
2416
+ const guide = s.guide;
2417
+ const responseId = randomId(24);
2418
+ this.state.surveysSubmitted.push(guide.id);
2419
+ this.persistState();
2420
+ this.recordEnd(guide, "completed");
2421
+ this.enqueue("survey_submitted", { guideId: guide.id, guideVersion: guide.version, properties: { responseId } });
2422
+ void postWithRetry(this.api, `/surveys/${encodeURIComponent(guide.id)}/responses`, { anonymousId: this.anonymousId, userId: this.user?.id || null, accountId: this.user?.account?.id || null, guideVersion: guide.version, answers, responseId });
2423
+ this.emitter.emit("survey:submit", { guide, answers, responseId });
2424
+ this.emitter.emit("guide:complete", guide);
2425
+ this.surveyView = { ...this.surveyView, submitted: true };
2426
+ this.notify();
2427
+ }
2428
+ closeSurvey(guideId) {
2429
+ if (this.activeSurvey?.guide.id !== guideId) return;
2430
+ this.finishSurvey(this.surveyView?.submitted ? "submitted" : "dismissed");
2431
+ }
2432
+ getResourceCenter() {
2433
+ const rc = this.config?.resourceCenter;
2434
+ return rc && rc.enabled ? rc : null;
2435
+ }
2436
+ async searchResourceCenter(q) {
2437
+ try {
2438
+ const res = await this.api.get("/resource-center/search", { params: { q } });
2439
+ return res.ok && res.data ? res.data.articles : [];
2440
+ } catch {
2441
+ return [];
2442
+ }
2443
+ }
2444
+ articleViewed(article) {
2445
+ this.enqueue("article_viewed", { properties: { articleId: article.id, slug: article.slug } });
2446
+ }
2447
+ // ---------------------------------------------------------------------------
2448
+ // Checklists
2449
+ // ---------------------------------------------------------------------------
2450
+ getChecklistProgress(idOrKey) {
2451
+ const id = this.findGuide(idOrKey)?.id ?? idOrKey;
2452
+ const p = this.state.checklists[id];
2453
+ return p ? { completedTaskIds: [...p.completedTaskIds], completedAt: p.completedAt ?? null, dismissedAt: p.dismissedAt ?? null } : { completedTaskIds: [], completedAt: null, dismissedAt: null };
2454
+ }
2455
+ completeChecklistTask(idOrKey, taskId) {
2456
+ this.whenHydrated(() => {
2457
+ const guide = this.findGuide(idOrKey);
2458
+ const task = guide?.config.checklist?.tasks.find((t) => t.id === taskId);
2459
+ if (guide && task) this.markTaskDone(guide, task);
2460
+ });
2461
+ }
2462
+ markTaskDone(guide, task) {
2463
+ const config = guide.config.checklist;
2464
+ if (!config) return;
2465
+ const progress = this.state.checklists[guide.id] ?? (this.state.checklists[guide.id] = { completedTaskIds: [] });
2466
+ if (progress.completedTaskIds.includes(task.id)) return;
2467
+ progress.completedTaskIds.push(task.id);
2468
+ this.enqueue("checklist_task_completed", { guideId: guide.id, guideVersion: guide.version, properties: { taskId: task.id } });
2469
+ const complete = isChecklistComplete(config, progress) && !progress.completedAt;
2470
+ if (complete) {
2471
+ progress.completedAt = nowIso();
2472
+ this.enqueue("checklist_completed", { guideId: guide.id, guideVersion: guide.version });
2473
+ }
2474
+ this.persistState();
2475
+ void postWithRetry(this.api, `/checklists/${encodeURIComponent(guide.id)}/progress`, { anonymousId: this.anonymousId, userId: this.user?.id || null, guideVersion: guide.version, completedTaskIds: progress.completedTaskIds, completed: !!progress.completedAt });
2476
+ this.emitter.emit("checklist:task", { guide, task, progress: this.getChecklistProgress(guide.id) });
2477
+ if (complete) {
2478
+ this.emitter.emit("checklist:complete", guide);
2479
+ const w = this.checklists.get(guide.id);
2480
+ if (w) w.open = true;
2481
+ }
2482
+ this.notify();
2483
+ }
2484
+ completeChecklistTasksBy(kind, value) {
2485
+ if (!this.config) return;
2486
+ for (const guide of this.config.guides) {
2487
+ for (const task of guide.config.checklist?.tasks ?? []) {
2488
+ if (task.completion !== kind || !task.completionValue) continue;
2489
+ let hit = false;
2490
+ if (kind === "event") hit = value === task.completionValue;
2491
+ else if (kind === "url_visited") hit = screenMatches(screenPath(task.completionValue), value) || screenMatches(task.completionValue, value);
2492
+ else if (kind === "guide_completed") hit = value.id === task.completionValue || value.key === task.completionValue;
2493
+ else if (kind === "click") hit = guidelyIdFromSelector(task.completionValue) === value;
2494
+ if (hit) this.markTaskDone(guide, task);
2495
+ }
2496
+ }
2497
+ }
2498
+ openChecklist(idOrKey) {
2499
+ this.whenHydrated(() => {
2500
+ const guide = this.findGuide(idOrKey);
2501
+ if (!guide?.config.checklist) return;
2502
+ if (!this.checklists.has(guide.id)) {
2503
+ const p = this.state.checklists[guide.id];
2504
+ if (p?.dismissedAt) {
2505
+ delete p.dismissedAt;
2506
+ this.persistState();
2507
+ }
2508
+ this.showChecklist(guide, true);
2509
+ }
2510
+ this.setChecklistOpen(guide.id, true);
2511
+ });
2512
+ }
2513
+ closeChecklist(idOrKey) {
2514
+ const guide = this.findGuide(idOrKey);
2515
+ if (guide) this.setChecklistOpen(guide.id, false);
2516
+ }
2517
+ // ---------------------------------------------------------------------------
2518
+ // Public guide controls
2519
+ // ---------------------------------------------------------------------------
2520
+ /** Starts a guide by id or key, bypassing targeting and frequency rules. */
2521
+ startGuide(idOrKey, options = {}) {
2522
+ const guide = this.findGuide(idOrKey);
2523
+ if (!guide) {
2524
+ this.reportError(new Error(`[Guidely] startGuide: unknown guide "${idOrKey}"`));
2525
+ return false;
2526
+ }
2527
+ if (guide.kind === "checklist") {
2528
+ this.openChecklist(guide.id);
2529
+ return true;
2530
+ }
2531
+ if (this.hasExclusive()) this.stopGuide();
2532
+ this.flows.get(guide.id)?.controller.destroy();
2533
+ this.flows.delete(guide.id);
2534
+ this.show(guide, true, options.stepId);
2535
+ return true;
2536
+ }
2537
+ /** Stops the active guide silently (no dismiss event). */
2538
+ stopGuide() {
2539
+ const a = this.active;
2540
+ if (a) {
2541
+ this.active = null;
2542
+ a.controller.destroy();
2543
+ }
2544
+ if (this.activeSurvey) {
2545
+ this.activeSurvey = null;
2546
+ this.surveyView = null;
2547
+ }
2548
+ this.notify();
2549
+ }
2550
+ /** Dismisses the active guide (records a dismissal). */
2551
+ dismissGuide() {
2552
+ if (this.active) this.active.controller.dismiss();
2553
+ else if (this.activeSurvey) this.finishSurvey("dismissed");
2554
+ }
2555
+ getActiveGuide() {
2556
+ return this.hasExclusive();
2557
+ }
2558
+ getGuides() {
2559
+ return this.config?.guides ?? [];
2560
+ }
2561
+ isGuideCompleted(idOrKey) {
2562
+ const guide = this.findGuide(idOrKey);
2563
+ return !!this.state.guides[guide?.id ?? idOrKey]?.completedAt;
2564
+ }
2565
+ /** Impressions, completion and dismissal for a guide. */
2566
+ getGuideState(idOrKey) {
2567
+ const guide = this.findGuide(idOrKey);
2568
+ return { impressions: 0, ...this.state.guides[guide?.id ?? idOrKey] ?? {} };
2569
+ }
2570
+ openResourceCenter() {
2571
+ if (this.messenger) return this.openMessenger("help");
2572
+ if (!this.getResourceCenter()) {
2573
+ this.log("openResourceCenter: the resource center is not enabled or published for this environment");
2574
+ return;
2575
+ }
2576
+ if (this.resourceCenterOpen) return;
2577
+ this.resourceCenterOpen = true;
2578
+ this.enqueue("resource_center_opened");
2579
+ this.emitter.emit("resource-center:open");
2580
+ this.notify();
2581
+ }
2582
+ closeResourceCenter() {
2583
+ if (this.messenger) return this.closeMessenger();
2584
+ if (!this.resourceCenterOpen) return;
2585
+ this.resourceCenterOpen = false;
2586
+ this.notify();
2587
+ }
2588
+ // ---------------------------------------------------------------------------
2589
+ // Scout messenger
2590
+ // ---------------------------------------------------------------------------
2591
+ /** Creates or removes the messenger to match the config (on only when enabled for this platform). */
2592
+ syncMessenger() {
2593
+ const cfg = this.config?.messenger;
2594
+ if (cfg?.enabled) {
2595
+ if (this.messenger) this.messenger.updateConfig(cfg);
2596
+ else {
2597
+ this.messenger = new Messenger(cfg, {
2598
+ baseUrl: this.options.apiUrl,
2599
+ publicKey: this.options.projectId,
2600
+ store: this.store,
2601
+ fetch: this.options.fetch ?? fetch,
2602
+ streamPost: (this.options.fetch ? null : xhrStreamPost()) ?? fetchStreamPost(this.options.fetch ?? fetch),
2603
+ anonymousId: () => this.anonymousId,
2604
+ userId: () => this.user?.id || null,
2605
+ pageUrl: () => this.collectUrls() && this.screenName ? this.currentUrl() : void 0,
2606
+ onOpen: () => {
2607
+ this.enqueue("messenger_opened");
2608
+ this.emitter.emit("messenger:open");
2609
+ },
2610
+ onMessage: (message, conversation) => this.emitter.emit("conversation:message", { message, conversation }),
2611
+ onStartGuide: (key) => this.setTimer(() => this.startGuide(key), 0),
2612
+ onError: (err) => this.log("messenger", err instanceof Error ? err.message : err)
2613
+ });
2614
+ this.resourceCenterOpen = false;
2615
+ }
2616
+ } else if (this.messenger) {
2617
+ this.messenger.destroy();
2618
+ this.messenger = null;
2619
+ }
2620
+ this.notify();
2621
+ }
2622
+ /** The Scout messenger store when Scout is on for this platform (rendered by the overlay). */
2623
+ getMessenger() {
2624
+ return this.messenger;
2625
+ }
2626
+ /** Opens the Scout messenger, optionally on a tab. No-op when Scout is off for this platform. */
2627
+ openMessenger(tab) {
2628
+ this.whenHydrated(() => this.messenger?.open(tab));
2629
+ }
2630
+ closeMessenger() {
2631
+ this.messenger?.close();
2632
+ }
2633
+ /** Opens a new messenger conversation, optionally pre-filling the message. */
2634
+ showNewMessage(text) {
2635
+ this.whenHydrated(() => this.messenger?.showNewMessage(text));
2636
+ }
2637
+ on(event, cb) {
2638
+ return this.emitter.on(event, cb);
2639
+ }
2640
+ off(event, cb) {
2641
+ this.emitter.off(event, cb);
2642
+ }
2643
+ isClientReady() {
2644
+ return this.isReady;
2645
+ }
2646
+ /** Snapshot for debugging. */
2647
+ getState() {
2648
+ return {
2649
+ ready: this.isReady,
2650
+ destroyed: this.destroyed,
2651
+ anonymousId: this.anonymousId,
2652
+ user: this.user,
2653
+ consent: this.consent,
2654
+ screen: this.screenName,
2655
+ configVersion: this.config?.configVersion ?? null,
2656
+ guides: this.getGuides().map((g) => ({ id: g.id, key: g.key, kind: g.kind })),
2657
+ activeGuide: this.active ? { id: this.active.guide.id, key: this.active.guide.key, ...this.active.controller.getState() } : this.activeSurvey ? { id: this.activeSurvey.guide.id, key: this.activeSurvey.guide.key } : null,
2658
+ queued: this.queued.map((q) => q.guide.key),
2659
+ widgets: [...this.flows.keys(), ...this.checklists.keys()],
2660
+ guideState: this.state.guides,
2661
+ checklists: this.state.checklists,
2662
+ audienceIds: this.state.audienceIds,
2663
+ session: this.session,
2664
+ pendingEvents: this.queue?.size ?? 0,
2665
+ targets: this.targets.ids()
2666
+ };
2667
+ }
2668
+ // ---------------------------------------------------------------------------
2669
+ // Internals
2670
+ // ---------------------------------------------------------------------------
2671
+ persistState() {
2672
+ writeJson(this.store, KEYS.state(this.options.projectId), this.state);
2673
+ }
2674
+ persistSession() {
2675
+ this.session.lastActiveAt = nowIso();
2676
+ writeJson(this.store, KEYS.session, this.session);
2677
+ }
2678
+ reportError(err) {
2679
+ const error = err instanceof Error ? err : new Error(String(err));
2680
+ this.log("error", error.message);
2681
+ try {
2682
+ this.options.onError?.(error);
2683
+ } catch {
2684
+ }
2685
+ this.emitter.emit("error", error);
2686
+ }
2687
+ log(...args) {
2688
+ if (this.options.debug && typeof console !== "undefined") console.log("[Guidely]", ...args);
2689
+ }
2690
+ };
2691
+ function forPlatform(config, platform) {
2692
+ return config.guides.every((g) => supportsPlatform(g.config, platform)) ? config : { ...config, guides: config.guides.filter((g) => supportsPlatform(g.config, platform)) };
2693
+ }
2694
+
2695
+ // src/ui/Overlay.tsx
2696
+ import { useCallback as useCallback2, useEffect as useEffect5, useRef as useRef3, useState as useState5, useSyncExternalStore as useSyncExternalStore2 } from "react";
2697
+ import { Platform as Platform5, Pressable as Pressable7, StatusBar, StyleSheet as StyleSheet9, Text as Text8, useColorScheme, useWindowDimensions, View as View9 } from "react-native";
2698
+
2699
+ // src/ui/Checklist.tsx
2700
+ import { Pressable as Pressable2, ScrollView, StyleSheet as StyleSheet3, Text as Text3, View as View3 } from "react-native";
2701
+
2702
+ // src/ui/primitives.tsx
2703
+ import { Pressable, StyleSheet, Text, View } from "react-native";
2704
+ import { jsx } from "react/jsx-runtime";
2705
+ function GButton({ label, variant = "primary", onPress, palette: p, style, accessibilityHint, disabled }) {
2706
+ const filled = variant === "primary";
2707
+ const outlined = variant === "secondary";
2708
+ return /* @__PURE__ */ jsx(
2709
+ Pressable,
2710
+ {
2711
+ accessibilityRole: "button",
2712
+ accessibilityLabel: label,
2713
+ accessibilityHint,
2714
+ accessibilityState: { disabled: !!disabled },
2715
+ disabled,
2716
+ onPress,
2717
+ hitSlop: 6,
2718
+ style: ({ pressed }) => [
2719
+ styles.btn,
2720
+ { borderRadius: Math.min(p.radius, 10), opacity: disabled ? 0.5 : pressed ? 0.75 : 1 },
2721
+ filled && { backgroundColor: p.primary },
2722
+ outlined && { borderWidth: 1, borderColor: p.border, backgroundColor: p.background },
2723
+ variant === "link" && styles.link,
2724
+ style
2725
+ ],
2726
+ children: /* @__PURE__ */ jsx(Text, { style: [styles.btnText, { color: filled ? p.onPrimary : variant === "link" ? p.primary : variant === "ghost" ? p.muted : p.text, fontFamily: p.fontFamily }, variant === "link" && styles.underline], numberOfLines: 1, children: label })
2727
+ }
2728
+ );
2729
+ }
2730
+ function CloseButton({ onPress, palette: p, label = "Close" }) {
2731
+ return /* @__PURE__ */ jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress, hitSlop: 10, style: ({ pressed }) => [styles.close, { opacity: pressed ? 0.6 : 1 }], children: /* @__PURE__ */ jsx(Text, { style: [styles.closeText, { color: p.muted }], children: "\xD7" }) });
2732
+ }
2733
+ function Surface({ palette: p, children, style }) {
2734
+ return /* @__PURE__ */ jsx(View, { style: [styles.surface, { backgroundColor: p.background, borderRadius: p.radius, borderColor: p.border }, style], children });
2735
+ }
2736
+ var styles = StyleSheet.create({
2737
+ btn: { minHeight: 36, paddingHorizontal: 14, paddingVertical: 8, alignItems: "center", justifyContent: "center" },
2738
+ link: { paddingHorizontal: 4 },
2739
+ btnText: { fontSize: 14, fontWeight: "600" },
2740
+ underline: { textDecorationLine: "underline" },
2741
+ close: { width: 28, height: 28, alignItems: "center", justifyContent: "center", marginTop: -4, marginRight: -6 },
2742
+ closeText: { fontSize: 24, lineHeight: 26, fontWeight: "400" },
2743
+ surface: {
2744
+ borderWidth: StyleSheet.hairlineWidth,
2745
+ shadowColor: "#000",
2746
+ shadowOpacity: 0.18,
2747
+ shadowRadius: 18,
2748
+ shadowOffset: { width: 0, height: 8 },
2749
+ elevation: 10
2750
+ }
2751
+ });
2752
+
2753
+ // src/ui/RichText.tsx
2754
+ import { useMemo } from "react";
2755
+ import { Platform, StyleSheet as StyleSheet2, Text as Text2, View as View2 } from "react-native";
2756
+
2757
+ // src/core/html.ts
2758
+ var ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\xA0", mdash: "\u2014", ndash: "\u2013", hellip: "\u2026", rsquo: "\u2019", lsquo: "\u2018", rdquo: "\u201D", ldquo: "\u201C", copy: "\xA9", reg: "\xAE", trade: "\u2122", times: "\xD7", rarr: "\u2192", larr: "\u2190", bull: "\u2022" };
2759
+ function decodeEntities(s) {
2760
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
2761
+ if (e[0] === "#") {
2762
+ const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
2763
+ return Number.isFinite(code) && code > 0 && code < 1114112 ? String.fromCodePoint(code) : m;
2764
+ }
2765
+ return ENTITIES[e.toLowerCase()] ?? m;
2766
+ });
2767
+ }
2768
+ var BLOCK_TAGS = /* @__PURE__ */ new Set(["p", "div", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "ul", "ol", "table", "tr", "pre"]);
2769
+ var SKIP_TAGS = /* @__PURE__ */ new Set(["script", "style", "head", "title", "svg", "template"]);
2770
+ function attr(attrs, name) {
2771
+ const m = attrs.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
2772
+ return m ? decodeEntities(m[2] ?? m[3] ?? m[4] ?? "") : void 0;
2773
+ }
2774
+ function parseHtml(html) {
2775
+ const blocks = [];
2776
+ const style = { bold: 0, italic: 0, underline: 0, code: 0 };
2777
+ const hrefs = [];
2778
+ const lists = [];
2779
+ let current = null;
2780
+ let skipping = null;
2781
+ const flush = () => {
2782
+ if (current) {
2783
+ const runs = current.runs;
2784
+ while (runs.length && !runs[0].text.trim()) runs.shift();
2785
+ while (runs.length && !runs[runs.length - 1].text.trim()) runs.pop();
2786
+ if (runs.length) {
2787
+ runs[0].text = runs[0].text.replace(/^[ \t\n]+/, "");
2788
+ runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/[ \t\n]+$/, "");
2789
+ blocks.push(current);
2790
+ }
2791
+ }
2792
+ current = null;
2793
+ };
2794
+ const open = (kind, level = 1, marker) => {
2795
+ flush();
2796
+ current = { kind, level, marker, runs: [] };
2797
+ };
2798
+ const push = (text) => {
2799
+ if (!text) return;
2800
+ if (!current) current = { kind: "paragraph", level: 1, runs: [] };
2801
+ const run = { text };
2802
+ if (style.bold) run.bold = true;
2803
+ if (style.italic) run.italic = true;
2804
+ if (style.underline) run.underline = true;
2805
+ if (style.code) run.code = true;
2806
+ if (hrefs.length) run.href = hrefs[hrefs.length - 1];
2807
+ const last = current.runs[current.runs.length - 1];
2808
+ if (last && last.bold === run.bold && last.italic === run.italic && last.underline === run.underline && last.code === run.code && last.href === run.href) last.text += text;
2809
+ else current.runs.push(run);
2810
+ };
2811
+ const re = /<!--[\s\S]*?-->|<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>|([^<]+|<)/g;
2812
+ let m;
2813
+ while (m = re.exec(html || "")) {
2814
+ const [token, rawTag, attrs = "", text] = m;
2815
+ if (text !== void 0) {
2816
+ if (skipping) continue;
2817
+ push(decodeEntities(text.replace(/\s+/g, " ")));
2818
+ continue;
2819
+ }
2820
+ if (!rawTag) continue;
2821
+ const tag = rawTag.toLowerCase();
2822
+ const closing = token.startsWith("</");
2823
+ if (skipping) {
2824
+ if (closing && tag === skipping) skipping = null;
2825
+ continue;
2826
+ }
2827
+ if (SKIP_TAGS.has(tag)) {
2828
+ if (!closing && !token.endsWith("/>")) skipping = tag;
2829
+ continue;
2830
+ }
2831
+ if (closing) {
2832
+ if (tag === "b" || tag === "strong") style.bold = Math.max(0, style.bold - 1);
2833
+ else if (tag === "i" || tag === "em") style.italic = Math.max(0, style.italic - 1);
2834
+ else if (tag === "u") style.underline = Math.max(0, style.underline - 1);
2835
+ else if (tag === "code") style.code = Math.max(0, style.code - 1);
2836
+ else if (tag === "a") hrefs.pop();
2837
+ else if (tag === "ul" || tag === "ol") {
2838
+ lists.pop();
2839
+ flush();
2840
+ } else if (BLOCK_TAGS.has(tag)) flush();
2841
+ continue;
2842
+ }
2843
+ switch (tag) {
2844
+ case "br":
2845
+ push("\n");
2846
+ break;
2847
+ case "b":
2848
+ case "strong":
2849
+ style.bold += 1;
2850
+ break;
2851
+ case "i":
2852
+ case "em":
2853
+ style.italic += 1;
2854
+ break;
2855
+ case "u":
2856
+ style.underline += 1;
2857
+ break;
2858
+ case "code":
2859
+ style.code += 1;
2860
+ break;
2861
+ case "a":
2862
+ hrefs.push(attr(attrs, "href") ?? "");
2863
+ break;
2864
+ case "ul":
2865
+ case "ol":
2866
+ flush();
2867
+ lists.push({ ordered: tag === "ol", n: 0 });
2868
+ break;
2869
+ case "li": {
2870
+ const list = lists[lists.length - 1];
2871
+ if (list) list.n += 1;
2872
+ open("list-item", Math.max(1, lists.length), list?.ordered ? `${list.n}.` : "\u2022");
2873
+ break;
2874
+ }
2875
+ case "h1":
2876
+ case "h2":
2877
+ case "h3":
2878
+ case "h4":
2879
+ case "h5":
2880
+ case "h6":
2881
+ open("heading", Math.min(3, Number(tag[1])));
2882
+ break;
2883
+ case "blockquote":
2884
+ open("quote");
2885
+ break;
2886
+ default:
2887
+ if (BLOCK_TAGS.has(tag)) open("paragraph");
2888
+ else if (tag === "img") {
2889
+ const alt = attr(attrs, "alt");
2890
+ if (alt) push(alt);
2891
+ }
2892
+ }
2893
+ }
2894
+ flush();
2895
+ return blocks;
2896
+ }
2897
+ function htmlToText(html) {
2898
+ return parseHtml(html).map((b) => (b.marker ? `${b.marker} ` : "") + b.runs.map((r) => r.text).join("")).join("\n");
2899
+ }
2900
+
2901
+ // src/ui/RichText.tsx
2902
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
2903
+ var MONO = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" });
2904
+ var HEADING_SIZE = [0, 20, 17, 15];
2905
+ function runStyle(run, p) {
2906
+ return {
2907
+ fontWeight: run.bold ? "700" : void 0,
2908
+ fontStyle: run.italic ? "italic" : void 0,
2909
+ textDecorationLine: run.underline || run.href ? "underline" : void 0,
2910
+ fontFamily: run.code ? MONO : void 0,
2911
+ backgroundColor: run.code ? p.surface : void 0,
2912
+ color: run.href ? p.primary : void 0
2913
+ };
2914
+ }
2915
+ function RichText({ html, palette: p, onLink, size = 15 }) {
2916
+ const blocks = useMemo(() => parseHtml(html), [html]);
2917
+ if (!blocks.length) return null;
2918
+ const base = { color: p.text, fontSize: size, lineHeight: Math.round(size * 1.42), fontFamily: p.fontFamily };
2919
+ return /* @__PURE__ */ jsx2(View2, { style: styles2.wrap, children: blocks.map((b, i) => {
2920
+ const runs = b.runs.map((r, j) => /* @__PURE__ */ jsx2(Text2, { style: runStyle(r, p), onPress: r.href ? () => onLink(r.href) : void 0, accessibilityRole: r.href ? "link" : void 0, children: r.text }, j));
2921
+ if (b.kind === "list-item") {
2922
+ return /* @__PURE__ */ jsxs(View2, { style: [styles2.li, { paddingLeft: (b.level - 1) * 14 }], children: [
2923
+ /* @__PURE__ */ jsx2(Text2, { style: [base, styles2.marker], children: b.marker }),
2924
+ /* @__PURE__ */ jsx2(Text2, { style: [base, styles2.flex], children: runs })
2925
+ ] }, i);
2926
+ }
2927
+ if (b.kind === "heading") {
2928
+ const fs = HEADING_SIZE[b.level] ?? 15;
2929
+ return /* @__PURE__ */ jsx2(Text2, { accessibilityRole: "header", style: [base, { fontSize: fs, lineHeight: Math.round(fs * 1.3), fontWeight: "700" }], children: runs }, i);
2930
+ }
2931
+ return /* @__PURE__ */ jsx2(Text2, { style: [base, b.kind === "quote" && { borderLeftWidth: 3, borderLeftColor: p.border, paddingLeft: 8, color: p.muted }], children: runs }, i);
2932
+ }) });
2933
+ }
2934
+ var styles2 = StyleSheet2.create({
2935
+ wrap: { gap: 6 },
2936
+ li: { flexDirection: "row", gap: 6 },
2937
+ marker: { minWidth: 14 },
2938
+ flex: { flex: 1 }
2939
+ });
2940
+
2941
+ // src/ui/Checklist.tsx
2942
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
2943
+ function Checklist({ view, client, palette: p, geometry, showLauncher, launcherBottom }) {
2944
+ const config = view.guide.config.checklist;
2945
+ if (!config) return null;
2946
+ const done = new Set(view.progress.completedTaskIds);
2947
+ const total = config.tasks.length;
2948
+ const completed = config.tasks.filter((t) => done.has(t.id)).length;
2949
+ const finished = !!view.progress.completedAt;
2950
+ const left = config.position === "bottom-left";
2951
+ const { insets, viewport } = geometry;
2952
+ const side = left ? { left: insets.left + 16 } : { right: insets.right + 16 };
2953
+ const width = Math.min(viewport.width - insets.left - insets.right - 32, 360);
2954
+ const nextOpen = config.ordered ? config.tasks.find((t) => !done.has(t.id))?.id : void 0;
2955
+ const bottom = insets.bottom + launcherBottom;
2956
+ return /* @__PURE__ */ jsxs2(View3, { pointerEvents: "box-none", style: StyleSheet3.absoluteFill, children: [
2957
+ view.open ? /* @__PURE__ */ jsx3(Surface, { palette: p, style: [styles3.panel, side, { width, bottom: bottom + (showLauncher ? 56 : 0), maxHeight: viewport.height - insets.top - bottom - 80 }], children: /* @__PURE__ */ jsxs2(View3, { accessibilityViewIsModal: true, style: styles3.panelInner, children: [
2958
+ /* @__PURE__ */ jsxs2(View3, { style: styles3.head, children: [
2959
+ /* @__PURE__ */ jsxs2(View3, { style: styles3.flex, children: [
2960
+ /* @__PURE__ */ jsx3(Text3, { accessibilityRole: "header", style: [styles3.title, { color: p.text, fontFamily: p.fontFamily }], children: finished ? config.completionTitle : config.launcherLabel }),
2961
+ /* @__PURE__ */ jsxs2(Text3, { style: [styles3.sub, { color: p.muted }], children: [
2962
+ completed,
2963
+ " of ",
2964
+ total,
2965
+ " complete"
2966
+ ] })
2967
+ ] }),
2968
+ /* @__PURE__ */ jsx3(CloseButton, { palette: p, onPress: () => client.setChecklistOpen(view.guide.id, false), label: "Close checklist" })
2969
+ ] }),
2970
+ /* @__PURE__ */ jsx3(View3, { style: [styles3.bar, { backgroundColor: p.surface }], accessibilityRole: "progressbar", accessibilityValue: { min: 0, max: total, now: completed }, children: /* @__PURE__ */ jsx3(View3, { style: [styles3.fill, { backgroundColor: p.primary, width: `${total ? completed / total * 100 : 0}%` }] }) }),
2971
+ finished && config.completionBody ? /* @__PURE__ */ jsx3(RichText, { html: config.completionBody, palette: p, onLink: (u) => client.navigate(u), size: 14 }) : null,
2972
+ /* @__PURE__ */ jsx3(ScrollView, { style: styles3.tasks, contentContainerStyle: styles3.tasksContent, children: config.tasks.map((task) => {
2973
+ const isDone = done.has(task.id);
2974
+ const locked = !!nextOpen && !isDone && task.id !== nextOpen;
2975
+ return /* @__PURE__ */ jsxs2(
2976
+ Pressable2,
2977
+ {
2978
+ disabled: locked,
2979
+ accessibilityRole: "button",
2980
+ accessibilityState: { checked: isDone, disabled: locked },
2981
+ accessibilityLabel: `${task.title}${isDone ? ", done" : ""}`,
2982
+ onPress: () => client.checklistTaskPressed(view.guide.id, task.id),
2983
+ style: ({ pressed }) => [styles3.task, { borderColor: p.border, opacity: locked ? 0.5 : pressed ? 0.7 : 1 }],
2984
+ children: [
2985
+ /* @__PURE__ */ jsx3(View3, { style: [styles3.check, isDone ? { backgroundColor: p.primary, borderColor: p.primary } : { borderColor: p.border }], children: isDone ? /* @__PURE__ */ jsx3(Text3, { style: [styles3.tick, { color: p.onPrimary }], children: "\u2713" }) : null }),
2986
+ /* @__PURE__ */ jsxs2(View3, { style: styles3.flex, children: [
2987
+ /* @__PURE__ */ jsxs2(Text3, { style: [styles3.taskTitle, { color: p.text, fontFamily: p.fontFamily }, isDone && { textDecorationLine: "line-through", color: p.muted }], children: [
2988
+ task.title,
2989
+ !task.required ? /* @__PURE__ */ jsx3(Text3, { style: { color: p.muted, fontWeight: "400" }, children: " (optional)" }) : null
2990
+ ] }),
2991
+ task.description ? /* @__PURE__ */ jsx3(Text3, { style: [styles3.taskDesc, { color: p.muted }], children: task.description }) : null
2992
+ ] })
2993
+ ]
2994
+ },
2995
+ task.id
2996
+ );
2997
+ }) }),
2998
+ config.dismissible ? /* @__PURE__ */ jsx3(GButton, { label: finished ? "Close for good" : "Dismiss checklist", variant: "ghost", palette: p, onPress: () => client.dismissChecklist(view.guide.id), style: styles3.dismiss }) : null
2999
+ ] }) }) : null,
3000
+ showLauncher ? /* @__PURE__ */ jsxs2(
3001
+ Pressable2,
3002
+ {
3003
+ accessibilityRole: "button",
3004
+ accessibilityLabel: `${config.launcherLabel}, ${completed} of ${total} complete`,
3005
+ accessibilityState: { expanded: view.open },
3006
+ onPress: () => client.setChecklistOpen(view.guide.id, !view.open),
3007
+ style: ({ pressed }) => [styles3.launcher, side, { bottom, backgroundColor: p.primary, opacity: pressed ? 0.85 : 1 }],
3008
+ children: [
3009
+ /* @__PURE__ */ jsx3(View3, { style: [styles3.count, { borderColor: p.onPrimary }], children: /* @__PURE__ */ jsx3(Text3, { style: [styles3.countText, { color: p.onPrimary }], children: finished ? "\u2713" : `${completed}/${total}` }) }),
3010
+ /* @__PURE__ */ jsx3(Text3, { style: [styles3.launcherText, { color: p.onPrimary, fontFamily: p.fontFamily }], numberOfLines: 1, children: config.launcherLabel })
3011
+ ]
3012
+ }
3013
+ ) : null
3014
+ ] });
3015
+ }
3016
+ var styles3 = StyleSheet3.create({
3017
+ flex: { flex: 1 },
3018
+ panel: { position: "absolute" },
3019
+ panelInner: { padding: 16, gap: 10, flexShrink: 1 },
3020
+ head: { flexDirection: "row", alignItems: "flex-start", gap: 8 },
3021
+ title: { fontSize: 17, fontWeight: "700" },
3022
+ sub: { fontSize: 13, marginTop: 2 },
3023
+ bar: { height: 6, borderRadius: 3, overflow: "hidden" },
3024
+ fill: { height: 6, borderRadius: 3 },
3025
+ tasks: { flexGrow: 0 },
3026
+ tasksContent: { gap: 8 },
3027
+ task: { flexDirection: "row", gap: 10, alignItems: "flex-start", borderWidth: StyleSheet3.hairlineWidth, borderRadius: 10, padding: 10 },
3028
+ check: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, alignItems: "center", justifyContent: "center", marginTop: 1 },
3029
+ tick: { fontSize: 13, fontWeight: "800" },
3030
+ taskTitle: { fontSize: 15, fontWeight: "600" },
3031
+ taskDesc: { fontSize: 13, marginTop: 2 },
3032
+ dismiss: { alignSelf: "center" },
3033
+ launcher: {
3034
+ position: "absolute",
3035
+ flexDirection: "row",
3036
+ alignItems: "center",
3037
+ gap: 8,
3038
+ paddingVertical: 10,
3039
+ paddingHorizontal: 14,
3040
+ borderRadius: 24,
3041
+ shadowColor: "#000",
3042
+ shadowOpacity: 0.22,
3043
+ shadowRadius: 12,
3044
+ shadowOffset: { width: 0, height: 4 },
3045
+ elevation: 8,
3046
+ maxWidth: 240
3047
+ },
3048
+ count: { minWidth: 30, height: 22, borderRadius: 11, borderWidth: 1.5, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
3049
+ countText: { fontSize: 11, fontWeight: "700" },
3050
+ launcherText: { fontSize: 14, fontWeight: "700", flexShrink: 1 }
3051
+ });
3052
+
3053
+ // src/ui/Messenger.tsx
3054
+ import { useCallback, useEffect, useMemo as useMemo2, useRef, useState, useSyncExternalStore } from "react";
3055
+ import { ActivityIndicator, Keyboard, Modal, Platform as Platform2, Pressable as Pressable3, ScrollView as ScrollView2, StyleSheet as StyleSheet4, Text as Text4, TextInput, View as View4 } from "react-native";
3056
+ import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
3057
+ function useMessenger(store) {
3058
+ return useSyncExternalStore(
3059
+ useCallback((cb) => store.subscribe(cb), [store]),
3060
+ () => store.getSnapshot(),
3061
+ () => store.getSnapshot()
3062
+ );
3063
+ }
3064
+ function useKeyboardHeight() {
3065
+ const [h, setH] = useState(0);
3066
+ useEffect(() => {
3067
+ if (Platform2.OS !== "ios") return;
3068
+ const show = Keyboard.addListener("keyboardWillShow", (e) => setH(e.endCoordinates.height));
3069
+ const hide = Keyboard.addListener("keyboardWillHide", () => setH(0));
3070
+ return () => {
3071
+ show.remove();
3072
+ hide.remove();
3073
+ };
3074
+ }, []);
3075
+ return h;
3076
+ }
3077
+ function ago(iso) {
3078
+ const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1e3);
3079
+ if (s < 60) return "now";
3080
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
3081
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
3082
+ return `${Math.floor(s / 86400)}d`;
3083
+ }
3084
+ function MessengerLauncher({ store, palette: p, style }) {
3085
+ const snap = useMessenger(store);
3086
+ if (snap.open) return null;
3087
+ const label = snap.unread ? `Open ${snap.config.name}, ${snap.unread} unread` : `Open ${snap.config.name}`;
3088
+ return /* @__PURE__ */ jsxs3(Pressable3, { accessibilityRole: "button", accessibilityLabel: label, onPress: () => store.open(), style: ({ pressed }) => [styles4.launcher, style, { backgroundColor: p.primary, opacity: pressed ? 0.85 : 1 }], children: [
3089
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.launcherIcon, { color: p.onPrimary }], children: "\u{1F4AC}" }),
3090
+ snap.unread ? /* @__PURE__ */ jsx4(View4, { style: styles4.badge, children: /* @__PURE__ */ jsx4(Text4, { style: styles4.badgeText, children: snap.unread }) }) : null
3091
+ ] });
3092
+ }
3093
+ function MessengerSheet({ store, client, palette: p, topInset, bottomInset }) {
3094
+ const snap = useMessenger(store);
3095
+ const keyboard = useKeyboardHeight();
3096
+ const v = snap.view;
3097
+ const title = v.kind === "thread" ? snap.thread?.conversation?.subject || "New conversation" : v.kind === "article" ? "" : v.tab === "home" ? snap.config.name : v.tab === "messages" ? "Messages" : "Help";
3098
+ return /* @__PURE__ */ jsx4(Modal, { visible: snap.open, animationType: "slide", presentationStyle: Platform2.OS === "ios" ? "pageSheet" : "fullScreen", onRequestClose: () => v.kind === "tab" ? store.close() : store.back(), children: /* @__PURE__ */ jsxs3(View4, { style: [styles4.sheet, { backgroundColor: p.background, paddingTop: Platform2.OS === "ios" ? 8 : topInset, paddingBottom: keyboard }], children: [
3099
+ /* @__PURE__ */ jsxs3(View4, { style: [styles4.header, { borderColor: p.border }], children: [
3100
+ v.kind !== "tab" ? /* @__PURE__ */ jsx4(Pressable3, { accessibilityRole: "button", accessibilityLabel: "Back", onPress: () => store.back(), hitSlop: 10, children: /* @__PURE__ */ jsx4(Text4, { style: [styles4.back, { color: p.primary }], children: "\u2039 Back" }) }) : null,
3101
+ /* @__PURE__ */ jsx4(Text4, { accessibilityRole: "header", numberOfLines: 1, style: [styles4.title, { color: p.text, fontFamily: p.fontFamily }], children: title }),
3102
+ /* @__PURE__ */ jsx4(CloseButton, { palette: p, onPress: () => store.close(), label: `Close ${snap.config.name}` })
3103
+ ] }),
3104
+ /* @__PURE__ */ jsx4(View4, { style: styles4.flex, children: v.kind === "thread" ? /* @__PURE__ */ jsx4(Thread, { store, palette: p, bottomInset: keyboard ? 0 : bottomInset }) : v.kind === "article" ? /* @__PURE__ */ jsx4(Article, { article: snap.article, palette: p, client }) : v.tab === "home" ? /* @__PURE__ */ jsx4(Home, { store, palette: p }) : v.tab === "messages" ? /* @__PURE__ */ jsx4(Conversations, { store, palette: p }) : /* @__PURE__ */ jsx4(Help, { store, client, palette: p }) }),
3105
+ v.kind === "tab" && !keyboard ? /* @__PURE__ */ jsx4(Tabs, { store, active: v.tab, palette: p, bottomInset }) : null
3106
+ ] }) });
3107
+ }
3108
+ function Tabs({ store, active, palette: p, bottomInset }) {
3109
+ const unread = useMessenger(store).unread;
3110
+ const tabs = [
3111
+ ["home", "\u2302", "Home"],
3112
+ ["messages", "\u2709\uFE0E", "Messages"],
3113
+ ["help", "?", "Help"]
3114
+ ];
3115
+ return /* @__PURE__ */ jsx4(View4, { accessibilityRole: "tablist", style: [styles4.tabs, { borderColor: p.border, paddingBottom: Math.max(8, bottomInset) }], children: tabs.map(([tab, icon, label]) => {
3116
+ const on = tab === active;
3117
+ return /* @__PURE__ */ jsxs3(Pressable3, { accessibilityRole: "tab", accessibilityState: { selected: on }, accessibilityLabel: tab === "messages" && unread ? `${label}, ${unread} unread` : label, onPress: () => store.setTab(tab), style: styles4.tab, children: [
3118
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.tabIcon, { color: on ? p.primary : p.muted }], children: icon }),
3119
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.tabLabel, { color: on ? p.primary : p.muted }], children: label }),
3120
+ tab === "messages" && unread ? /* @__PURE__ */ jsx4(View4, { style: [styles4.dot, styles4.tabDot] }) : null
3121
+ ] }, tab);
3122
+ }) });
3123
+ }
3124
+ function Home({ store, palette: p }) {
3125
+ const snap = useMessenger(store);
3126
+ const recent = snap.conversations[0];
3127
+ const [q, setQ] = useState("");
3128
+ return /* @__PURE__ */ jsxs3(ScrollView2, { contentContainerStyle: styles4.content, keyboardShouldPersistTaps: "handled", children: [
3129
+ /* @__PURE__ */ jsxs3(View4, { style: [styles4.hero, { backgroundColor: p.primary, borderRadius: p.radius }], children: [
3130
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.heroTitle, { color: p.onPrimary }], children: snap.config.greeting || `Hi! I'm ${snap.config.name}.` }),
3131
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.heroSub, { color: p.onPrimary }], children: snap.config.aiAvailable ? `${snap.config.name} answers right away${snap.config.handoffEnabled ? ", and the team is here when you need a person" : ""}.` : "The team usually replies within a few hours." })
3132
+ ] }),
3133
+ /* @__PURE__ */ jsx4(GButton, { label: "Send us a message", palette: p, onPress: () => store.showNewMessage(), style: styles4.fullWidth }),
3134
+ recent ? /* @__PURE__ */ jsxs3(View4, { style: styles4.section, children: [
3135
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.sectionTitle, { color: p.muted }], children: "RECENT" }),
3136
+ /* @__PURE__ */ jsx4(ConversationRow, { c: recent, palette: p, onPress: () => void store.openConversation(recent.id) })
3137
+ ] }) : null,
3138
+ /* @__PURE__ */ jsxs3(View4, { style: styles4.section, children: [
3139
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.sectionTitle, { color: p.muted }], children: "SEARCH FOR HELP" }),
3140
+ /* @__PURE__ */ jsx4(
3141
+ TextInput,
3142
+ {
3143
+ value: q,
3144
+ onChangeText: setQ,
3145
+ onSubmitEditing: () => {
3146
+ pendingSearch.current = q;
3147
+ store.setTab("help");
3148
+ },
3149
+ returnKeyType: "search",
3150
+ placeholder: "Search articles",
3151
+ placeholderTextColor: p.muted,
3152
+ accessibilityLabel: "Search help articles",
3153
+ style: [styles4.search, { color: p.text, backgroundColor: p.surface, borderRadius: Math.min(10, p.radius) }]
3154
+ }
3155
+ )
3156
+ ] })
3157
+ ] });
3158
+ }
3159
+ var pendingSearch = { current: "" };
3160
+ function ConversationRow({ c, palette: p, onPress }) {
3161
+ return /* @__PURE__ */ jsxs3(Pressable3, { accessibilityRole: "button", accessibilityLabel: `${c.subject || "Conversation"}${c.unread ? ", unread" : ""}`, onPress, style: ({ pressed }) => [styles4.row, { borderColor: p.border, opacity: pressed ? 0.7 : 1 }], children: [
3162
+ /* @__PURE__ */ jsxs3(View4, { style: styles4.flex, children: [
3163
+ /* @__PURE__ */ jsx4(Text4, { numberOfLines: 1, style: [styles4.rowTitle, { color: p.text }], children: c.subject || "Conversation" }),
3164
+ /* @__PURE__ */ jsx4(Text4, { numberOfLines: 1, style: [styles4.rowSub, { color: p.muted }], children: c.lastMessagePreview || (c.status === "closed" ? "Closed" : " ") })
3165
+ ] }),
3166
+ /* @__PURE__ */ jsxs3(View4, { style: styles4.rowEnd, children: [
3167
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.time, { color: p.muted }], children: ago(c.lastMessageAt) }),
3168
+ c.unread ? /* @__PURE__ */ jsx4(View4, { style: [styles4.dot, { backgroundColor: p.primary }] }) : null
3169
+ ] })
3170
+ ] });
3171
+ }
3172
+ function Conversations({ store, palette: p }) {
3173
+ const snap = useMessenger(store);
3174
+ return /* @__PURE__ */ jsxs3(ScrollView2, { contentContainerStyle: styles4.content, children: [
3175
+ snap.conversations.length ? snap.conversations.map((c) => /* @__PURE__ */ jsx4(ConversationRow, { c, palette: p, onPress: () => void store.openConversation(c.id) }, c.id)) : /* @__PURE__ */ jsx4(Text4, { style: [styles4.empty, { color: p.muted }], children: "No conversations yet." }),
3176
+ /* @__PURE__ */ jsx4(GButton, { label: "New conversation", palette: p, onPress: () => store.showNewMessage(), style: styles4.fullWidth })
3177
+ ] });
3178
+ }
3179
+ function Bubble({ m, text, name, palette: p, onArticle, onGuide }) {
3180
+ if (m.author === "system")
3181
+ return /* @__PURE__ */ jsx4(Text4, { accessibilityRole: "text", style: [styles4.system, { color: p.muted }], children: text });
3182
+ const mine = m.author === "user";
3183
+ return /* @__PURE__ */ jsxs3(View4, { style: [styles4.bubbleWrap, mine ? styles4.mine : styles4.theirs], children: [
3184
+ !mine ? /* @__PURE__ */ jsx4(Text4, { style: [styles4.author, { color: p.muted }], children: m.author === "agent" ? m.authorName || "Team" : name }) : null,
3185
+ /* @__PURE__ */ jsx4(View4, { style: [styles4.bubble, { borderRadius: Math.max(12, p.radius) }, mine ? { backgroundColor: p.primary } : { backgroundColor: p.surface }], children: m.status === "pending" && !text ? /* @__PURE__ */ jsx4(TypingDots, { color: p.muted }) : /* @__PURE__ */ jsx4(Text4, { style: [styles4.bubbleText, { color: mine ? p.onPrimary : p.text }], children: text }) }),
3186
+ m.citations.length || m.suggestedGuides.length ? /* @__PURE__ */ jsxs3(View4, { style: styles4.chips, children: [
3187
+ m.citations.map((c) => /* @__PURE__ */ jsx4(Pressable3, { accessibilityRole: "link", accessibilityLabel: `Article: ${c.title}`, onPress: () => onArticle(c.articleId), style: [styles4.chip, { borderColor: p.border }], children: /* @__PURE__ */ jsxs3(Text4, { style: [styles4.chipText, { color: p.text }], children: [
3188
+ "\u{1F4C4} ",
3189
+ c.title
3190
+ ] }) }, c.articleId)),
3191
+ m.suggestedGuides.map((g) => /* @__PURE__ */ jsx4(Pressable3, { accessibilityRole: "button", accessibilityLabel: `Show me: ${g.name}`, onPress: () => onGuide(g.key), style: [styles4.chip, { borderColor: p.primary }], children: /* @__PURE__ */ jsxs3(Text4, { style: [styles4.chipText, { color: p.primary }], children: [
3192
+ "\u25B6 Show me: ",
3193
+ g.name
3194
+ ] }) }, g.key))
3195
+ ] }) : null
3196
+ ] });
3197
+ }
3198
+ function TypingDots({ color }) {
3199
+ return /* @__PURE__ */ jsx4(ActivityIndicator, { size: "small", color, accessibilityLabel: "Typing" });
3200
+ }
3201
+ function Thread({ store, palette: p, bottomInset }) {
3202
+ const snap = useMessenger(store);
3203
+ const t = snap.thread;
3204
+ const [text, setText] = useState(t?.draft ?? "");
3205
+ const scroll = useRef(null);
3206
+ const convo = t?.conversation ?? null;
3207
+ useEffect(() => {
3208
+ if (t?.draft) setText(t.draft);
3209
+ }, [t?.draft]);
3210
+ const streamingIds = useMemo2(() => Object.keys(t?.streaming ?? {}).filter((id) => !t?.messages.some((m) => m.id === id)), [t]);
3211
+ if (!t) return null;
3212
+ const send = () => {
3213
+ const value = text;
3214
+ setText("");
3215
+ void store.send(value);
3216
+ };
3217
+ const canHandoff = snap.config.handoffEnabled && snap.config.aiAvailable && convo?.status === "bot" && !t.sending;
3218
+ return /* @__PURE__ */ jsxs3(View4, { style: styles4.flex, children: [
3219
+ /* @__PURE__ */ jsxs3(ScrollView2, { ref: scroll, contentContainerStyle: styles4.thread, onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }), keyboardShouldPersistTaps: "handled", children: [
3220
+ !t.messages.length && !t.sending ? /* @__PURE__ */ jsx4(Text4, { style: [styles4.empty, { color: p.muted }], children: snap.config.greeting || "Ask us anything." }) : null,
3221
+ t.messages.map((m) => /* @__PURE__ */ jsx4(Bubble, { m, text: t.streaming[m.id] ?? m.body, name: snap.config.name, palette: p, onArticle: (id) => void store.openArticle(id), onGuide: (k) => store.startGuide(k) }, m.id)),
3222
+ streamingIds.map((id) => /* @__PURE__ */ jsx4(Bubble, { m: { id, author: "scout", body: "", citations: [], suggestedGuides: [], status: "pending", createdAt: (/* @__PURE__ */ new Date()).toISOString() }, text: t.streaming[id] ?? "", name: snap.config.name, palette: p, onArticle: () => {
3223
+ }, onGuide: () => {
3224
+ } }, id)),
3225
+ t.sending && !streamingIds.length && snap.config.aiAvailable && t.messages.length ? /* @__PURE__ */ jsx4(TypingDots, { color: p.muted }) : null,
3226
+ t.error ? /* @__PURE__ */ jsx4(Text4, { accessibilityLiveRegion: "polite", style: styles4.error, children: t.error }) : null,
3227
+ convo?.status === "closed" && convo.rating == null ? /* @__PURE__ */ jsx4(Rating, { palette: p, onRate: (n) => void store.rate(n) }) : null,
3228
+ convo?.status === "closed" && convo.rating != null ? /* @__PURE__ */ jsx4(Text4, { style: [styles4.system, { color: p.muted }], children: "Thanks for rating this conversation." }) : null
3229
+ ] }),
3230
+ canHandoff ? /* @__PURE__ */ jsx4(GButton, { label: "Talk to a person", variant: "secondary", palette: p, onPress: () => void store.handoff(), style: styles4.handoff }) : null,
3231
+ /* @__PURE__ */ jsxs3(View4, { style: [styles4.composer, { borderColor: p.border, paddingBottom: Math.max(10, bottomInset) }], children: [
3232
+ /* @__PURE__ */ jsx4(
3233
+ TextInput,
3234
+ {
3235
+ value: text,
3236
+ onChangeText: (v) => setText(v.slice(0, 4e3)),
3237
+ placeholder: convo ? "Reply\u2026" : "Write a message\u2026",
3238
+ placeholderTextColor: p.muted,
3239
+ multiline: true,
3240
+ accessibilityLabel: "Message",
3241
+ style: [styles4.input, { color: p.text, backgroundColor: p.surface, borderRadius: Math.min(18, p.radius + 6) }]
3242
+ }
3243
+ ),
3244
+ /* @__PURE__ */ jsx4(GButton, { label: "Send", palette: p, onPress: send, disabled: !text.trim() || t.sending })
3245
+ ] })
3246
+ ] });
3247
+ }
3248
+ function Rating({ palette: p, onRate }) {
3249
+ return /* @__PURE__ */ jsxs3(View4, { style: styles4.rating, children: [
3250
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.ratingTitle, { color: p.text }], children: "How did we do?" }),
3251
+ /* @__PURE__ */ jsx4(View4, { accessibilityRole: "radiogroup", style: styles4.stars, children: [1, 2, 3, 4, 5].map((n) => /* @__PURE__ */ jsx4(Pressable3, { accessibilityRole: "radio", accessibilityLabel: `${n} of 5`, onPress: () => onRate(n), hitSlop: 6, children: /* @__PURE__ */ jsx4(Text4, { style: [styles4.star, { color: p.primary }], children: "\u2605" }) }, n)) })
3252
+ ] });
3253
+ }
3254
+ function Help({ store, client, palette: p }) {
3255
+ const snap = useMessenger(store);
3256
+ const rc = client.getResourceCenter();
3257
+ const [q, setQ] = useState(pendingSearch.current);
3258
+ const [results, setResults] = useState(null);
3259
+ const [faq, setFaq] = useState(null);
3260
+ useEffect(() => {
3261
+ pendingSearch.current = "";
3262
+ }, []);
3263
+ useEffect(() => {
3264
+ const query = q.trim();
3265
+ if (query.length < 2) return setResults(null);
3266
+ let alive = true;
3267
+ const t = setTimeout(() => void store.search(query).then((r) => alive && setResults(r)), 250);
3268
+ return () => {
3269
+ alive = false;
3270
+ clearTimeout(t);
3271
+ };
3272
+ }, [q, store]);
3273
+ const guides = client.getGuides();
3274
+ const recommended = (rc?.recommendedGuideIds ?? []).map((id) => guides.find((g) => g.id === id || g.key === id)).filter((g) => !!g);
3275
+ const articles = snap.articles;
3276
+ const row = (key, title, sub, onPress, icon = "\u203A") => /* @__PURE__ */ jsxs3(Pressable3, { accessibilityRole: "button", accessibilityLabel: title, onPress, style: ({ pressed }) => [styles4.row, { borderColor: p.border, opacity: pressed ? 0.7 : 1 }], children: [
3277
+ /* @__PURE__ */ jsxs3(View4, { style: styles4.flex, children: [
3278
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.rowTitle, { color: p.text }], children: title }),
3279
+ sub ? /* @__PURE__ */ jsx4(Text4, { numberOfLines: 2, style: [styles4.rowSub, { color: p.muted }], children: sub }) : null
3280
+ ] }),
3281
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.chev, { color: p.muted }], children: icon })
3282
+ ] }, key);
3283
+ const open = (a) => a.externalUrl ? client.navigate(a.externalUrl) : void store.openArticle(a.id);
3284
+ const section = (title, children) => /* @__PURE__ */ jsxs3(View4, { style: styles4.section, children: [
3285
+ /* @__PURE__ */ jsx4(Text4, { accessibilityRole: "header", style: [styles4.sectionTitle, { color: p.muted }], children: title.toUpperCase() }),
3286
+ children
3287
+ ] }, title);
3288
+ const collections = [...articles?.collections ?? []].sort((a, b) => a.order - b.order);
3289
+ const loose = (articles?.articles ?? []).filter((a) => !a.collectionId || !collections.some((c) => c.id === a.collectionId));
3290
+ return /* @__PURE__ */ jsxs3(ScrollView2, { contentContainerStyle: styles4.content, keyboardShouldPersistTaps: "handled", children: [
3291
+ /* @__PURE__ */ jsx4(TextInput, { value: q, onChangeText: setQ, placeholder: "Search articles", placeholderTextColor: p.muted, accessibilityLabel: "Search help articles", returnKeyType: "search", style: [styles4.search, { color: p.text, backgroundColor: p.surface, borderRadius: Math.min(10, p.radius) }] }),
3292
+ results ? section(`Results for \u201C${q.trim()}\u201D`, results.length ? results.map((a) => row(a.id, a.title, a.summary, () => open(a))) : /* @__PURE__ */ jsx4(Text4, { style: [styles4.rowSub, { color: p.muted }], children: "No articles found." })) : /* @__PURE__ */ jsxs3(Fragment, { children: [
3293
+ recommended.length ? section("Recommended", recommended.map((g) => row(g.id, g.name, void 0, () => store.startGuide(g.key), "\u25B6"))) : null,
3294
+ !articles ? /* @__PURE__ */ jsx4(ActivityIndicator, { color: p.muted }) : null,
3295
+ collections.map((c) => {
3296
+ const items = (articles?.articles ?? []).filter((a) => a.collectionId === c.id);
3297
+ return items.length ? section(c.name, items.map((a) => row(a.id, a.title, a.summary, () => open(a)))) : null;
3298
+ }),
3299
+ loose.length ? section("Articles", loose.map((a) => row(a.id, a.title, a.summary, () => open(a)))) : null,
3300
+ rc?.faqs.length ? section(
3301
+ "FAQ",
3302
+ rc.faqs.map((f) => /* @__PURE__ */ jsxs3(View4, { style: [styles4.row, styles4.faq, { borderColor: p.border }], children: [
3303
+ /* @__PURE__ */ jsxs3(Pressable3, { accessibilityRole: "button", accessibilityState: { expanded: faq === f.id }, onPress: () => setFaq(faq === f.id ? null : f.id), style: styles4.faqHead, children: [
3304
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.rowTitle, styles4.flex, { color: p.text }], children: f.question }),
3305
+ /* @__PURE__ */ jsx4(Text4, { style: [styles4.chev, { color: p.muted }], children: faq === f.id ? "\u2212" : "+" })
3306
+ ] }),
3307
+ faq === f.id ? /* @__PURE__ */ jsx4(RichText, { html: f.answer, palette: p, onLink: (u) => client.navigate(u), size: 14 }) : null
3308
+ ] }, f.id))
3309
+ ) : null,
3310
+ rc?.links.length ? section("Links", rc.links.map((l) => row(l.id, l.label, void 0, () => client.navigate(l.url), "\u2197"))) : null
3311
+ ] })
3312
+ ] });
3313
+ }
3314
+ function Article({ article, palette: p, client }) {
3315
+ if (!article) return /* @__PURE__ */ jsx4(ActivityIndicator, { style: styles4.loading, color: p.muted });
3316
+ return /* @__PURE__ */ jsxs3(ScrollView2, { contentContainerStyle: styles4.content, children: [
3317
+ /* @__PURE__ */ jsx4(Text4, { accessibilityRole: "header", style: [styles4.articleTitle, { color: p.text, fontFamily: p.fontFamily }], children: article.title }),
3318
+ /* @__PURE__ */ jsx4(RichText, { html: article.body, palette: p, onLink: (u) => client.navigate(u) })
3319
+ ] });
3320
+ }
3321
+ var styles4 = StyleSheet4.create({
3322
+ flex: { flex: 1 },
3323
+ launcher: { position: "absolute", width: 54, height: 54, borderRadius: 27, alignItems: "center", justifyContent: "center", elevation: 8, shadowColor: "#000", shadowOpacity: 0.22, shadowRadius: 12, shadowOffset: { width: 0, height: 4 } },
3324
+ launcherIcon: { fontSize: 24 },
3325
+ badge: { position: "absolute", top: -2, right: -2, minWidth: 20, height: 20, borderRadius: 10, backgroundColor: "#dc2626", alignItems: "center", justifyContent: "center", paddingHorizontal: 5, borderWidth: 2, borderColor: "#ffffff" },
3326
+ badgeText: { color: "#ffffff", fontSize: 11, fontWeight: "800" },
3327
+ sheet: { flex: 1 },
3328
+ header: { flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet4.hairlineWidth },
3329
+ back: { fontSize: 16, fontWeight: "600" },
3330
+ title: { flex: 1, fontSize: 18, fontWeight: "700" },
3331
+ tabs: { flexDirection: "row", borderTopWidth: StyleSheet4.hairlineWidth, paddingTop: 8 },
3332
+ tab: { flex: 1, alignItems: "center", gap: 2 },
3333
+ tabIcon: { fontSize: 18 },
3334
+ tabLabel: { fontSize: 11, fontWeight: "600" },
3335
+ tabDot: { position: "absolute", top: 0, right: "34%", backgroundColor: "#dc2626" },
3336
+ dot: { width: 8, height: 8, borderRadius: 4 },
3337
+ content: { padding: 16, gap: 16 },
3338
+ hero: { padding: 18, gap: 6 },
3339
+ heroTitle: { fontSize: 20, fontWeight: "800" },
3340
+ heroSub: { fontSize: 14, opacity: 0.9 },
3341
+ fullWidth: { alignSelf: "stretch" },
3342
+ section: { gap: 8 },
3343
+ sectionTitle: { fontSize: 12, fontWeight: "700", letterSpacing: 0.6 },
3344
+ search: { height: 42, paddingHorizontal: 12, fontSize: 15 },
3345
+ row: { flexDirection: "row", alignItems: "center", gap: 10, borderWidth: StyleSheet4.hairlineWidth, borderRadius: 10, paddingVertical: 12, paddingHorizontal: 12 },
3346
+ rowTitle: { fontSize: 15, fontWeight: "600" },
3347
+ rowSub: { fontSize: 13, marginTop: 2 },
3348
+ rowEnd: { alignItems: "flex-end", gap: 6 },
3349
+ time: { fontSize: 12 },
3350
+ chev: { fontSize: 18 },
3351
+ empty: { textAlign: "center", paddingVertical: 24, fontSize: 14 },
3352
+ faq: { flexDirection: "column", alignItems: "stretch" },
3353
+ faqHead: { flexDirection: "row", alignItems: "center", gap: 10 },
3354
+ thread: { padding: 16, gap: 12 },
3355
+ system: { textAlign: "center", fontSize: 12, paddingHorizontal: 24 },
3356
+ bubbleWrap: { maxWidth: "86%", gap: 4 },
3357
+ mine: { alignSelf: "flex-end", alignItems: "flex-end" },
3358
+ theirs: { alignSelf: "flex-start", alignItems: "flex-start" },
3359
+ author: { fontSize: 12, fontWeight: "600", marginLeft: 4 },
3360
+ bubble: { paddingHorizontal: 14, paddingVertical: 10 },
3361
+ bubbleText: { fontSize: 15, lineHeight: 21 },
3362
+ chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
3363
+ chip: { borderWidth: 1, borderRadius: 14, paddingHorizontal: 10, paddingVertical: 5 },
3364
+ chipText: { fontSize: 13, fontWeight: "600" },
3365
+ error: { color: "#dc2626", fontSize: 13, textAlign: "center" },
3366
+ handoff: { alignSelf: "center", marginBottom: 8 },
3367
+ composer: { flexDirection: "row", alignItems: "flex-end", gap: 8, paddingHorizontal: 12, paddingTop: 10, borderTopWidth: StyleSheet4.hairlineWidth },
3368
+ input: { flex: 1, minHeight: 40, maxHeight: 120, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 10, fontSize: 15 },
3369
+ rating: { alignItems: "center", gap: 6, paddingVertical: 8 },
3370
+ ratingTitle: { fontSize: 15, fontWeight: "600" },
3371
+ stars: { flexDirection: "row", gap: 8 },
3372
+ star: { fontSize: 30 },
3373
+ articleTitle: { fontSize: 22, fontWeight: "700" },
3374
+ loading: { marginTop: 40 }
3375
+ });
3376
+
3377
+ // src/ui/ResourceCenter.tsx
3378
+ import { useEffect as useEffect2, useMemo as useMemo3, useState as useState2 } from "react";
3379
+ import { Modal as Modal2, Platform as Platform3, Pressable as Pressable4, ScrollView as ScrollView3, StyleSheet as StyleSheet5, Text as Text5, TextInput as TextInput2, View as View5 } from "react-native";
3380
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
3381
+ function ResourceCenter({ client, palette: p, open, topInset, bottomInset }) {
3382
+ const rc = client.getResourceCenter();
3383
+ const [query, setQuery] = useState2("");
3384
+ const [results, setResults] = useState2(null);
3385
+ const [article, setArticle] = useState2(null);
3386
+ const [faq, setFaq] = useState2(null);
3387
+ useEffect2(() => {
3388
+ if (!open) {
3389
+ setQuery("");
3390
+ setResults(null);
3391
+ setArticle(null);
3392
+ }
3393
+ }, [open]);
3394
+ useEffect2(() => {
3395
+ const q = query.trim();
3396
+ if (q.length < 2) {
3397
+ setResults(null);
3398
+ return;
3399
+ }
3400
+ let alive = true;
3401
+ const t = setTimeout(() => {
3402
+ void client.searchResourceCenter(q).then((r) => alive && setResults(r));
3403
+ }, 250);
3404
+ return () => {
3405
+ alive = false;
3406
+ clearTimeout(t);
3407
+ };
3408
+ }, [client, query]);
3409
+ const guides = client.getGuides();
3410
+ const recommended = useMemo3(() => (rc?.recommendedGuideIds ?? []).map((id) => guides.find((g) => g.id === id || g.key === id)).filter((g) => !!g && g.kind !== "checklist"), [rc, guides]);
3411
+ const checklists = useMemo3(() => (rc?.checklistGuideIds ?? []).map((id) => guides.find((g) => g.id === id || g.key === id)).filter((g) => !!g && g.kind === "checklist"), [rc, guides]);
3412
+ if (!rc) return null;
3413
+ const close = () => client.closeResourceCenter();
3414
+ const openArticle = (a) => {
3415
+ if (a.externalUrl) return client.navigate(a.externalUrl);
3416
+ client.articleViewed(a);
3417
+ setArticle(a);
3418
+ };
3419
+ const row = (key, title, subtitle, onPress, icon = "\u203A") => /* @__PURE__ */ jsxs4(Pressable4, { accessibilityRole: "button", accessibilityLabel: title, onPress, style: ({ pressed }) => [styles5.row, { borderColor: p.border, opacity: pressed ? 0.7 : 1 }], children: [
3420
+ /* @__PURE__ */ jsxs4(View5, { style: styles5.flex, children: [
3421
+ /* @__PURE__ */ jsx5(Text5, { style: [styles5.rowTitle, { color: p.text, fontFamily: p.fontFamily }], children: title }),
3422
+ subtitle ? /* @__PURE__ */ jsx5(Text5, { style: [styles5.rowSub, { color: p.muted }], numberOfLines: 2, children: subtitle }) : null
3423
+ ] }),
3424
+ /* @__PURE__ */ jsx5(Text5, { style: [styles5.chev, { color: p.muted }], children: icon })
3425
+ ] }, key);
3426
+ const section = (title, children) => /* @__PURE__ */ jsxs4(View5, { style: styles5.section, children: [
3427
+ /* @__PURE__ */ jsx5(Text5, { accessibilityRole: "header", style: [styles5.sectionTitle, { color: p.muted }], children: title.toUpperCase() }),
3428
+ children
3429
+ ] }, title);
3430
+ const collections = [...rc.collections].sort((a, b) => a.order - b.order);
3431
+ const loose = rc.articles.filter((a) => !a.collectionId || !rc.collections.some((c) => c.id === a.collectionId));
3432
+ return /* @__PURE__ */ jsx5(Modal2, { visible: open, animationType: "slide", presentationStyle: Platform3.OS === "ios" ? "pageSheet" : "fullScreen", onRequestClose: () => article ? setArticle(null) : close(), children: /* @__PURE__ */ jsxs4(View5, { style: [styles5.sheet, { backgroundColor: p.background, paddingTop: Platform3.OS === "ios" ? 8 : topInset }], children: [
3433
+ /* @__PURE__ */ jsxs4(View5, { style: [styles5.header, { borderColor: p.border }], children: [
3434
+ article ? /* @__PURE__ */ jsx5(Pressable4, { accessibilityRole: "button", accessibilityLabel: "Back", onPress: () => setArticle(null), hitSlop: 10, children: /* @__PURE__ */ jsx5(Text5, { style: [styles5.back, { color: p.primary }], children: "\u2039 Back" }) }) : null,
3435
+ /* @__PURE__ */ jsx5(Text5, { accessibilityRole: "header", style: [styles5.title, { color: p.text, fontFamily: p.fontFamily }], numberOfLines: 1, children: article ? "" : rc.title }),
3436
+ /* @__PURE__ */ jsx5(CloseButton, { palette: p, onPress: close, label: "Close help" })
3437
+ ] }),
3438
+ article ? /* @__PURE__ */ jsxs4(ScrollView3, { contentContainerStyle: [styles5.content, { paddingBottom: bottomInset + 24 }], children: [
3439
+ /* @__PURE__ */ jsx5(Text5, { accessibilityRole: "header", style: [styles5.articleTitle, { color: p.text, fontFamily: p.fontFamily }], children: article.title }),
3440
+ /* @__PURE__ */ jsx5(RichText, { html: article.body, palette: p, onLink: (u) => client.navigate(u) })
3441
+ ] }) : /* @__PURE__ */ jsxs4(ScrollView3, { contentContainerStyle: [styles5.content, { paddingBottom: bottomInset + 24 }], keyboardShouldPersistTaps: "handled", children: [
3442
+ /* @__PURE__ */ jsx5(
3443
+ TextInput2,
3444
+ {
3445
+ value: query,
3446
+ onChangeText: setQuery,
3447
+ placeholder: "Search help articles",
3448
+ placeholderTextColor: p.muted,
3449
+ accessibilityLabel: "Search help articles",
3450
+ clearButtonMode: "while-editing",
3451
+ returnKeyType: "search",
3452
+ style: [styles5.search, { color: p.text, backgroundColor: p.surface, borderRadius: Math.min(10, p.radius) }]
3453
+ }
3454
+ ),
3455
+ results ? section(
3456
+ `Results for \u201C${query.trim()}\u201D`,
3457
+ results.length ? results.map((a) => row(a.id, a.title, a.summary, () => openArticle(a))) : /* @__PURE__ */ jsx5(Text5, { style: [styles5.rowSub, { color: p.muted }], children: "No articles found." })
3458
+ ) : /* @__PURE__ */ jsxs4(Fragment2, { children: [
3459
+ recommended.length ? section("Recommended", recommended.map((g) => row(g.id, g.name, void 0, () => (close(), client.startGuide(g.id)), "\u25B6"))) : null,
3460
+ checklists.length ? section("Checklists", checklists.map((g) => row(g.id, g.name, void 0, () => (close(), client.openChecklist(g.id)), "\u2611"))) : null,
3461
+ collections.map((c) => {
3462
+ const items = rc.articles.filter((a) => a.collectionId === c.id);
3463
+ return items.length ? section(c.name, items.map((a) => row(a.id, a.title, a.summary, () => openArticle(a)))) : null;
3464
+ }),
3465
+ loose.length ? section("Articles", loose.map((a) => row(a.id, a.title, a.summary, () => openArticle(a)))) : null,
3466
+ rc.faqs.length ? section(
3467
+ "FAQ",
3468
+ rc.faqs.map((f) => /* @__PURE__ */ jsxs4(View5, { style: [styles5.row, styles5.faq, { borderColor: p.border }], children: [
3469
+ /* @__PURE__ */ jsxs4(Pressable4, { accessibilityRole: "button", accessibilityState: { expanded: faq === f.id }, onPress: () => setFaq(faq === f.id ? null : f.id), style: styles5.faqHead, children: [
3470
+ /* @__PURE__ */ jsx5(Text5, { style: [styles5.rowTitle, styles5.flex, { color: p.text }], children: f.question }),
3471
+ /* @__PURE__ */ jsx5(Text5, { style: [styles5.chev, { color: p.muted }], children: faq === f.id ? "\u2212" : "+" })
3472
+ ] }),
3473
+ faq === f.id ? /* @__PURE__ */ jsx5(RichText, { html: f.answer, palette: p, onLink: (u) => client.navigate(u), size: 14 }) : null
3474
+ ] }, f.id))
3475
+ ) : null,
3476
+ rc.links.length ? section("Links", rc.links.map((l) => row(l.id, l.label, void 0, () => client.navigate(l.url), "\u2197"))) : null
3477
+ ] })
3478
+ ] })
3479
+ ] }) });
3480
+ }
3481
+ var styles5 = StyleSheet5.create({
3482
+ flex: { flex: 1 },
3483
+ sheet: { flex: 1 },
3484
+ header: { flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet5.hairlineWidth },
3485
+ back: { fontSize: 16, fontWeight: "600" },
3486
+ title: { flex: 1, fontSize: 18, fontWeight: "700" },
3487
+ content: { padding: 16, gap: 18 },
3488
+ search: { height: 42, paddingHorizontal: 12, fontSize: 15 },
3489
+ section: { gap: 8 },
3490
+ sectionTitle: { fontSize: 12, fontWeight: "700", letterSpacing: 0.6 },
3491
+ row: { flexDirection: "row", alignItems: "center", gap: 10, borderWidth: StyleSheet5.hairlineWidth, borderRadius: 10, paddingVertical: 12, paddingHorizontal: 12 },
3492
+ rowTitle: { fontSize: 15, fontWeight: "600" },
3493
+ rowSub: { fontSize: 13, marginTop: 2 },
3494
+ chev: { fontSize: 18 },
3495
+ faq: { flexDirection: "column", alignItems: "stretch" },
3496
+ faqHead: { flexDirection: "row", alignItems: "center", gap: 10 },
3497
+ articleTitle: { fontSize: 22, fontWeight: "700" }
3498
+ });
3499
+
3500
+ // src/ui/StepLayer.tsx
3501
+ import { useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
3502
+ import { AccessibilityInfo, Animated, Easing, Pressable as Pressable5, StyleSheet as StyleSheet7, View as View7 } from "react-native";
3503
+
3504
+ // src/core/position.ts
3505
+ var MARGIN = 12;
3506
+ var ARROW_INSET = 18;
3507
+ var opposite = { top: "bottom", bottom: "top", left: "right", right: "left" };
3508
+ function positionCard(target, size, viewport, insets, placement, offset) {
3509
+ const [rawSide, align] = placement.split("-");
3510
+ const fits = (side2) => {
3511
+ switch (side2) {
3512
+ case "bottom":
3513
+ return target.y + target.height + offset + size.height <= viewport.height - insets.bottom - MARGIN;
3514
+ case "top":
3515
+ return target.y - offset - size.height >= insets.top + MARGIN;
3516
+ case "right":
3517
+ return target.x + target.width + offset + size.width <= viewport.width - insets.right - MARGIN;
3518
+ case "left":
3519
+ return target.x - offset - size.width >= insets.left + MARGIN;
3520
+ }
3521
+ };
3522
+ const order = rawSide === "auto" ? ["bottom", "top"] : [rawSide, opposite[rawSide], "bottom", "top"];
3523
+ const space = { top: target.y - insets.top, bottom: viewport.height - insets.bottom - (target.y + target.height) };
3524
+ const side = order.find(fits) ?? (space.bottom >= space.top ? "bottom" : "top");
3525
+ const clamp2 = (v, min, max) => Math.max(min, Math.min(max, v));
3526
+ if (side === "top" || side === "bottom") {
3527
+ const cx = target.x + target.width / 2;
3528
+ let x2 = align === "start" ? target.x : align === "end" ? target.x + target.width - size.width : cx - size.width / 2;
3529
+ x2 = clamp2(x2, insets.left + MARGIN, Math.max(insets.left + MARGIN, viewport.width - insets.right - MARGIN - size.width));
3530
+ let y2 = side === "bottom" ? target.y + target.height + offset : target.y - offset - size.height;
3531
+ y2 = clamp2(y2, insets.top + MARGIN, Math.max(insets.top + MARGIN, viewport.height - insets.bottom - MARGIN - size.height));
3532
+ return { x: x2, y: y2, side, arrow: clamp2(cx - x2, ARROW_INSET, size.width - ARROW_INSET) };
3533
+ }
3534
+ const cy = target.y + target.height / 2;
3535
+ let y = align === "start" ? target.y : align === "end" ? target.y + target.height - size.height : cy - size.height / 2;
3536
+ y = clamp2(y, insets.top + MARGIN, Math.max(insets.top + MARGIN, viewport.height - insets.bottom - MARGIN - size.height));
3537
+ const x = side === "right" ? target.x + target.width + offset : target.x - offset - size.width;
3538
+ return { x, y, side, arrow: clamp2(cy - y, ARROW_INSET, size.height - ARROW_INSET) };
3539
+ }
3540
+
3541
+ // src/ui/StepCard.tsx
3542
+ import { Image, StyleSheet as StyleSheet6, Text as Text6, View as View6 } from "react-native";
3543
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3544
+ function StepCard({ view, client, palette: p, width, style, compact }) {
3545
+ const { step, guide } = view;
3546
+ const buttons = visibleButtons(step, view.canGoBack);
3547
+ const body = step.type === "custom" ? step.customHtml || step.body : step.body;
3548
+ const showProgress = step.showProgress && view.total > 1;
3549
+ const onLink = (url) => client.navigate(url);
3550
+ return /* @__PURE__ */ jsx6(Surface, { palette: p, style: [{ width }, style], children: /* @__PURE__ */ jsxs5(View6, { accessible: false, accessibilityViewIsModal: view.asModal || step.type === "modal", style: compact ? styles6.compact : styles6.inner, children: [
3551
+ step.imageUrl ? /* @__PURE__ */ jsx6(Image, { source: { uri: step.imageUrl }, style: [styles6.image, { borderRadius: Math.max(0, p.radius - 4) }], resizeMode: "cover", accessibilityIgnoresInvertColors: true }) : null,
3552
+ step.title ? /* @__PURE__ */ jsxs5(View6, { style: styles6.header, children: [
3553
+ /* @__PURE__ */ jsx6(Text6, { accessibilityRole: "header", style: [styles6.title, { color: p.text, fontFamily: p.fontFamily }], children: step.title }),
3554
+ step.showClose ? /* @__PURE__ */ jsx6(CloseButton, { palette: p, onPress: () => client.closeStep(guide.id), label: "Dismiss guide" }) : null
3555
+ ] }) : null,
3556
+ body ? (
3557
+ // Without a title the close button floats beside the body instead of taking a row.
3558
+ /* @__PURE__ */ jsx6(View6, { style: !step.title && step.showClose ? styles6.bodyBesideClose : void 0, children: /* @__PURE__ */ jsx6(RichText, { html: body, palette: p, onLink }) })
3559
+ ) : null,
3560
+ !step.title && step.showClose ? /* @__PURE__ */ jsx6(View6, { style: compact ? styles6.floatingCloseCompact : styles6.floatingClose, children: /* @__PURE__ */ jsx6(CloseButton, { palette: p, onPress: () => client.closeStep(guide.id), label: "Dismiss guide" }) }) : null,
3561
+ showProgress || buttons.length ? /* @__PURE__ */ jsxs5(View6, { style: styles6.footer, children: [
3562
+ showProgress ? /* @__PURE__ */ jsxs5(Text6, { style: [styles6.progress, { color: p.muted }], accessibilityLabel: `Step ${view.index + 1} of ${view.total}`, children: [
3563
+ view.index + 1,
3564
+ " of ",
3565
+ view.total
3566
+ ] }) : /* @__PURE__ */ jsx6(View6, { style: styles6.flex }),
3567
+ /* @__PURE__ */ jsx6(View6, { style: styles6.buttons, children: buttons.map((b) => /* @__PURE__ */ jsx6(GButton, { label: b.label, variant: b.variant, palette: p, onPress: () => client.pressButton(guide.id, b) }, b.id)) })
3568
+ ] }) : null
3569
+ ] }) });
3570
+ }
3571
+ function announcement(view) {
3572
+ const parts = [view.step.title, htmlToText(view.step.body)].filter(Boolean);
3573
+ if (view.total > 1) parts.push(`Step ${view.index + 1} of ${view.total}`);
3574
+ return parts.join(". ");
3575
+ }
3576
+ var styles6 = StyleSheet6.create({
3577
+ inner: { padding: 16, gap: 10 },
3578
+ compact: { paddingHorizontal: 14, paddingVertical: 10, gap: 6 },
3579
+ image: { width: "100%", height: 140, marginBottom: 2 },
3580
+ header: { flexDirection: "row", alignItems: "flex-start", gap: 8 },
3581
+ title: { flex: 1, fontSize: 17, fontWeight: "700", lineHeight: 22 },
3582
+ flex: { flex: 1 },
3583
+ bodyBesideClose: { paddingRight: 24 },
3584
+ floatingClose: { position: "absolute", top: 16, right: 16 },
3585
+ floatingCloseCompact: { position: "absolute", top: 10, right: 14 },
3586
+ footer: { flexDirection: "row", alignItems: "center", gap: 8, marginTop: 4, flexWrap: "wrap" },
3587
+ progress: { flex: 1, fontSize: 12, fontWeight: "500" },
3588
+ buttons: { flexDirection: "row", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }
3589
+ });
3590
+
3591
+ // src/ui/StepLayer.tsx
3592
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
3593
+ function useTargetRect(client, targetId, origin) {
3594
+ const [rect, setRect] = useState3(null);
3595
+ useEffect3(() => {
3596
+ if (!targetId) {
3597
+ setRect(null);
3598
+ return;
3599
+ }
3600
+ let alive = true;
3601
+ const measure = async () => {
3602
+ const r = await client.targets.measure(targetId);
3603
+ if (!alive) return;
3604
+ setRect((prev) => {
3605
+ const next = r ? { x: r.x - origin.x, y: r.y - origin.y, width: r.width, height: r.height } : null;
3606
+ if (prev && next && Math.abs(prev.x - next.x) < 0.5 && Math.abs(prev.y - next.y) < 0.5 && prev.width === next.width && prev.height === next.height) return prev;
3607
+ return next;
3608
+ });
3609
+ };
3610
+ void measure();
3611
+ const off = client.targets.onChange(() => void measure());
3612
+ const timer = setInterval(() => void measure(), 250);
3613
+ return () => {
3614
+ alive = false;
3615
+ off();
3616
+ clearInterval(timer);
3617
+ };
3618
+ }, [client, targetId, origin.x, origin.y]);
3619
+ return rect;
3620
+ }
3621
+ function useFadeIn(key) {
3622
+ const opacity = useRef2(new Animated.Value(0)).current;
3623
+ useEffect3(() => {
3624
+ opacity.setValue(0);
3625
+ Animated.timing(opacity, { toValue: 1, duration: 160, easing: Easing.out(Easing.quad), useNativeDriver: true }).start();
3626
+ }, [key, opacity]);
3627
+ return opacity;
3628
+ }
3629
+ function StepLayer({ view, client, palette: p, geometry }) {
3630
+ const { step } = view;
3631
+ const rect = useTargetRect(client, view.asModal ? null : view.targetId, geometry.origin);
3632
+ const opacity = useFadeIn(view.key);
3633
+ const { viewport, insets } = geometry;
3634
+ const cardWidth = Math.min(viewport.width - 32, step.width ?? 340);
3635
+ useEffect3(() => {
3636
+ if (step.type === "hotspot" && !view.expanded) return;
3637
+ AccessibilityInfo.announceForAccessibility?.(announcement(view));
3638
+ }, [view.key, view.expanded]);
3639
+ const backdrop = (children) => /* @__PURE__ */ jsx7(Pressable5, { accessible: false, style: [StyleSheet7.absoluteFill, { backgroundColor: p.overlay }], onPress: () => client.backdropPressed(view.guide.id), children });
3640
+ const anchored = !view.asModal && ["tooltip", "popover", "spotlight", "hotspot"].includes(step.type);
3641
+ if (!anchored && step.type === "banner") {
3642
+ const top = step.position.startsWith("top");
3643
+ return /* @__PURE__ */ jsx7(Animated.View, { pointerEvents: "box-none", style: [styles7.banner, top ? { top: insets.top + 8 } : { bottom: insets.bottom + 8 }, { left: insets.left + 8, right: insets.right + 8, opacity }], children: /* @__PURE__ */ jsx7(StepCard, { view, client, palette: p, width: viewport.width - insets.left - insets.right - 16, compact: true }) });
3644
+ }
3645
+ if (!anchored && step.type === "slideout") {
3646
+ const wide = viewport.width >= 600;
3647
+ const right = !step.position.endsWith("left");
3648
+ const width = wide ? Math.min(380, cardWidth) : viewport.width - insets.left - insets.right - 24;
3649
+ return /* @__PURE__ */ jsx7(Animated.View, { pointerEvents: "box-none", style: [styles7.abs, { bottom: insets.bottom + 12, opacity }, wide ? right ? { right: insets.right + 16 } : { left: insets.left + 16 } : { left: insets.left + 12 }], children: /* @__PURE__ */ jsx7(StepCard, { view, client, palette: p, width }) });
3650
+ }
3651
+ if (!anchored) {
3652
+ return /* @__PURE__ */ jsxs6(Animated.View, { pointerEvents: "box-none", style: [StyleSheet7.absoluteFill, styles7.center, { opacity }], children: [
3653
+ step.showOverlay ? backdrop() : null,
3654
+ /* @__PURE__ */ jsx7(StepCard, { view, client, palette: p, width: Math.min(viewport.width - 40, step.width ?? 360) })
3655
+ ] });
3656
+ }
3657
+ if (!rect) return null;
3658
+ if (step.type === "hotspot") {
3659
+ const size = step.hotspot?.size ?? 14;
3660
+ return /* @__PURE__ */ jsxs6(View7, { pointerEvents: "box-none", style: StyleSheet7.absoluteFill, children: [
3661
+ /* @__PURE__ */ jsx7(Hotspot, { x: rect.x + rect.width - size / 2, y: rect.y - size / 2, size, pulse: step.hotspot?.pulse ?? true, color: p.primary, onPress: () => client.toggleHotspot(view.guide.id), label: step.title || "Show tip" }),
3662
+ view.expanded ? /* @__PURE__ */ jsx7(Anchored, { view, client, palette: p, geometry, rect, width: cardWidth }) : null
3663
+ ] });
3664
+ }
3665
+ if (step.type === "spotlight") {
3666
+ const pad = step.spotlightPadding;
3667
+ const hole = { x: rect.x - pad, y: rect.y - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 };
3668
+ const r = Math.max(0, Math.min(12, p.radius, hole.width / 2, hole.height / 2));
3669
+ const shade = { backgroundColor: p.overlay };
3670
+ const press = () => client.backdropPressed(view.guide.id);
3671
+ const corners = [
3672
+ [hole.x, hole.y, -r, -r],
3673
+ [hole.x + hole.width - r, hole.y, -2 * r, -r],
3674
+ [hole.x, hole.y + hole.height - r, -r, -2 * r],
3675
+ [hole.x + hole.width - r, hole.y + hole.height - r, -2 * r, -2 * r]
3676
+ ];
3677
+ return /* @__PURE__ */ jsxs6(Animated.View, { pointerEvents: "box-none", style: [StyleSheet7.absoluteFill, { opacity }], children: [
3678
+ /* @__PURE__ */ jsx7(Pressable5, { accessible: false, onPress: press, style: [styles7.abs, shade, { left: 0, right: 0, top: 0, height: Math.max(0, hole.y) }] }),
3679
+ /* @__PURE__ */ jsx7(Pressable5, { accessible: false, onPress: press, style: [styles7.abs, shade, { left: 0, right: 0, top: hole.y + hole.height, bottom: 0 }] }),
3680
+ /* @__PURE__ */ jsx7(Pressable5, { accessible: false, onPress: press, style: [styles7.abs, shade, { left: 0, width: Math.max(0, hole.x), top: hole.y, height: hole.height }] }),
3681
+ /* @__PURE__ */ jsx7(Pressable5, { accessible: false, onPress: press, style: [styles7.abs, shade, { left: hole.x + hole.width, right: 0, top: hole.y, height: hole.height }] }),
3682
+ r > 0 ? corners.map(([left, top, dx, dy], i) => /* @__PURE__ */ jsx7(View7, { pointerEvents: "none", style: [styles7.abs, { left, top, width: r, height: r, overflow: "hidden" }], children: /* @__PURE__ */ jsx7(View7, { style: [styles7.abs, { left: dx, top: dy, width: 4 * r, height: 4 * r, borderRadius: 2 * r, borderWidth: r, borderColor: p.overlay }] }) }, i)) : null,
3683
+ /* @__PURE__ */ jsx7(View7, { pointerEvents: "none", style: [styles7.abs, { left: hole.x, top: hole.y, width: hole.width, height: hole.height, borderRadius: r, borderWidth: 2, borderColor: p.primary }] }),
3684
+ /* @__PURE__ */ jsx7(Anchored, { view, client, palette: p, geometry, rect: hole, width: cardWidth })
3685
+ ] });
3686
+ }
3687
+ return /* @__PURE__ */ jsxs6(Animated.View, { pointerEvents: "box-none", style: [StyleSheet7.absoluteFill, { opacity }], children: [
3688
+ step.showOverlay ? backdrop() : null,
3689
+ /* @__PURE__ */ jsx7(Anchored, { view, client, palette: p, geometry, rect, width: cardWidth })
3690
+ ] });
3691
+ }
3692
+ var ARROW = 12;
3693
+ function Anchored({ view, client, palette: p, geometry, rect, width }) {
3694
+ const [height, setHeight] = useState3(0);
3695
+ const onLayout = (e) => setHeight(Math.round(e.nativeEvent.layout.height));
3696
+ const offset = view.step.offset + ARROW / 2;
3697
+ const pos = positionCard(rect, { width, height: height || 120 }, geometry.viewport, geometry.insets, view.step.placement, offset);
3698
+ return /* @__PURE__ */ jsxs6(View7, { pointerEvents: "box-none", style: [styles7.abs, { left: pos.x, top: pos.y, width, opacity: height ? 1 : 0 }], onLayout, children: [
3699
+ /* @__PURE__ */ jsx7(Arrow, { side: pos.side, at: pos.arrow, color: p.background, border: p.border }),
3700
+ /* @__PURE__ */ jsx7(StepCard, { view, client, palette: p, width })
3701
+ ] });
3702
+ }
3703
+ function Arrow({ side, at, color, border }) {
3704
+ const half = ARROW / 2;
3705
+ const place = side === "bottom" ? { top: -half, left: at - half } : side === "top" ? { bottom: -half, left: at - half } : side === "right" ? { left: -half, top: at - half } : { right: -half, top: at - half };
3706
+ return /* @__PURE__ */ jsx7(View7, { pointerEvents: "none", style: [styles7.abs, styles7.arrow, place, { backgroundColor: color, borderColor: border }] });
3707
+ }
3708
+ function Hotspot({ x, y, size, pulse, color, onPress, label }) {
3709
+ const scale = useRef2(new Animated.Value(0)).current;
3710
+ const [reduceMotion, setReduceMotion] = useState3(false);
3711
+ useEffect3(() => {
3712
+ let alive = true;
3713
+ AccessibilityInfo.isReduceMotionEnabled?.().then((v) => alive && setReduceMotion(v), () => {
3714
+ });
3715
+ return () => {
3716
+ alive = false;
3717
+ };
3718
+ }, []);
3719
+ useEffect3(() => {
3720
+ if (!pulse || reduceMotion) return;
3721
+ const loop = Animated.loop(Animated.timing(scale, { toValue: 1, duration: 1400, easing: Easing.out(Easing.ease), useNativeDriver: true }));
3722
+ loop.start();
3723
+ return () => loop.stop();
3724
+ }, [pulse, reduceMotion, scale]);
3725
+ return /* @__PURE__ */ jsxs6(Pressable5, { accessibilityRole: "button", accessibilityLabel: label, onPress, hitSlop: 14, style: [styles7.abs, { left: x, top: y, width: size, height: size }], children: [
3726
+ pulse && !reduceMotion ? /* @__PURE__ */ jsx7(
3727
+ Animated.View,
3728
+ {
3729
+ pointerEvents: "none",
3730
+ style: [styles7.abs, { width: size, height: size, borderRadius: size / 2, backgroundColor: color, opacity: scale.interpolate({ inputRange: [0, 1], outputRange: [0.55, 0] }), transform: [{ scale: scale.interpolate({ inputRange: [0, 1], outputRange: [1, 2.6] }) }] }]
3731
+ }
3732
+ ) : null,
3733
+ /* @__PURE__ */ jsx7(View7, { style: { width: size, height: size, borderRadius: size / 2, backgroundColor: color, borderWidth: 2, borderColor: "#ffffff" } })
3734
+ ] });
3735
+ }
3736
+ var styles7 = StyleSheet7.create({
3737
+ abs: { position: "absolute" },
3738
+ center: { alignItems: "center", justifyContent: "center" },
3739
+ banner: { position: "absolute" },
3740
+ arrow: { width: ARROW, height: ARROW, transform: [{ rotate: "45deg" }], borderWidth: StyleSheet7.hairlineWidth }
3741
+ });
3742
+
3743
+ // src/ui/Survey.tsx
3744
+ import { useEffect as useEffect4, useState as useState4 } from "react";
3745
+ import {
3746
+ AccessibilityInfo as AccessibilityInfo2,
3747
+ KeyboardAvoidingView,
3748
+ Platform as Platform4,
3749
+ Pressable as Pressable6,
3750
+ ScrollView as ScrollView4,
3751
+ StyleSheet as StyleSheet8,
3752
+ Text as Text7,
3753
+ TextInput as TextInput3,
3754
+ View as View8
3755
+ } from "react-native";
3756
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
3757
+ function Survey({
3758
+ view,
3759
+ client,
3760
+ palette: p,
3761
+ geometry
3762
+ }) {
3763
+ const config = view.guide.config.survey;
3764
+ const [answers, setAnswers] = useState4({});
3765
+ const [error, setError] = useState4("");
3766
+ useEffect4(() => {
3767
+ setAnswers({});
3768
+ setError("");
3769
+ AccessibilityInfo2.announceForAccessibility?.(
3770
+ `Survey: ${config?.questions[0]?.title ?? view.guide.name}`
3771
+ );
3772
+ }, [view.key]);
3773
+ if (!config) return null;
3774
+ const { insets, viewport } = geometry;
3775
+ const width = Math.min(viewport.width - insets.left - insets.right - 24, 420);
3776
+ const centered = config.position === "center";
3777
+ const set = (id, value) => setAnswers((prev) => {
3778
+ const next = { ...prev };
3779
+ if (value === void 0) delete next[id];
3780
+ else next[id] = value;
3781
+ for (const q of config.questions)
3782
+ if (!isQuestionVisible(q, next) && next[q.id] !== void 0) delete next[q.id];
3783
+ return next;
3784
+ });
3785
+ const submit = () => {
3786
+ const missing = config.questions.filter(
3787
+ (q) => q.required && isQuestionVisible(q, answers) && answers[q.id] === void 0
3788
+ );
3789
+ if (missing.length) {
3790
+ setError(`Please answer: ${missing.map((q) => q.title).join(", ")}`);
3791
+ return;
3792
+ }
3793
+ setError("");
3794
+ client.submitSurvey(view.guide.id, { ...answers });
3795
+ };
3796
+ const close = () => client.closeSurvey(view.guide.id);
3797
+ return (
3798
+ // KeyboardAvoidingView owns its bottom padding, so the safe-area padding lives on an inner view.
3799
+ /* @__PURE__ */ jsxs7(
3800
+ KeyboardAvoidingView,
3801
+ {
3802
+ pointerEvents: "box-none",
3803
+ behavior: Platform4.OS === "ios" ? "padding" : void 0,
3804
+ style: StyleSheet8.absoluteFill,
3805
+ children: [
3806
+ centered ? /* @__PURE__ */ jsx8(
3807
+ Pressable6,
3808
+ {
3809
+ accessible: false,
3810
+ style: [StyleSheet8.absoluteFill, { backgroundColor: p.overlay }],
3811
+ onPress: close
3812
+ }
3813
+ ) : null,
3814
+ /* @__PURE__ */ jsx8(
3815
+ View8,
3816
+ {
3817
+ pointerEvents: "box-none",
3818
+ style: [
3819
+ styles8.fill,
3820
+ centered ? styles8.center : styles8.bottom,
3821
+ !centered && { paddingBottom: insets.bottom + 12 }
3822
+ ],
3823
+ children: /* @__PURE__ */ jsx8(
3824
+ Surface,
3825
+ {
3826
+ palette: p,
3827
+ style: { width, maxHeight: viewport.height - insets.top - insets.bottom - 40 },
3828
+ children: view.submitted ? /* @__PURE__ */ jsxs7(View8, { style: styles8.inner, children: [
3829
+ /* @__PURE__ */ jsx8(
3830
+ Text7,
3831
+ {
3832
+ accessibilityRole: "header",
3833
+ style: [styles8.title, { color: p.text, fontFamily: p.fontFamily }],
3834
+ children: config.thankYouTitle
3835
+ }
3836
+ ),
3837
+ config.thankYouBody ? /* @__PURE__ */ jsx8(
3838
+ RichText,
3839
+ {
3840
+ html: config.thankYouBody,
3841
+ palette: p,
3842
+ onLink: (u) => client.navigate(u)
3843
+ }
3844
+ ) : null,
3845
+ /* @__PURE__ */ jsx8(GButton, { label: "Done", palette: p, onPress: close, style: styles8.end })
3846
+ ] }) : /* @__PURE__ */ jsxs7(ScrollView4, { contentContainerStyle: styles8.inner, keyboardShouldPersistTaps: "handled", children: [
3847
+ config.questions.filter((q) => isQuestionVisible(q, answers)).map((q, i) => /* @__PURE__ */ jsxs7(View8, { style: styles8.question, children: [
3848
+ /* @__PURE__ */ jsxs7(View8, { style: styles8.head, children: [
3849
+ /* @__PURE__ */ jsxs7(Text7, { style: [styles8.qTitle, { color: p.text, fontFamily: p.fontFamily }], children: [
3850
+ q.title,
3851
+ !q.required ? /* @__PURE__ */ jsx8(Text7, { style: { color: p.muted, fontWeight: "400" }, children: " (optional)" }) : null
3852
+ ] }),
3853
+ i === 0 ? /* @__PURE__ */ jsx8(CloseButton, { palette: p, onPress: close, label: "Dismiss survey" }) : null
3854
+ ] }),
3855
+ q.description ? /* @__PURE__ */ jsx8(Text7, { style: [styles8.desc, { color: p.muted }], children: q.description }) : null,
3856
+ /* @__PURE__ */ jsx8(
3857
+ QuestionInput,
3858
+ {
3859
+ q,
3860
+ value: answers[q.id],
3861
+ onChange: (v) => set(q.id, v),
3862
+ palette: p
3863
+ }
3864
+ )
3865
+ ] }, q.id)),
3866
+ error ? /* @__PURE__ */ jsx8(Text7, { accessibilityLiveRegion: "polite", style: [styles8.error], children: error }) : null,
3867
+ /* @__PURE__ */ jsx8(GButton, { label: "Submit", palette: p, onPress: submit, style: styles8.end })
3868
+ ] })
3869
+ }
3870
+ )
3871
+ }
3872
+ )
3873
+ ]
3874
+ }
3875
+ )
3876
+ );
3877
+ }
3878
+ function QuestionInput({
3879
+ q,
3880
+ value,
3881
+ onChange,
3882
+ palette: p
3883
+ }) {
3884
+ const chip = (selected) => [
3885
+ styles8.chip,
3886
+ {
3887
+ borderColor: selected ? p.primary : p.border,
3888
+ backgroundColor: selected ? p.primary : p.background,
3889
+ borderRadius: Math.min(8, p.radius)
3890
+ }
3891
+ ];
3892
+ const chipText = (selected) => [
3893
+ styles8.chipText,
3894
+ { color: selected ? p.onPrimary : p.text }
3895
+ ];
3896
+ switch (q.type) {
3897
+ case "nps":
3898
+ case "csat": {
3899
+ const min = q.type === "nps" ? 0 : 1;
3900
+ const max = q.type === "nps" ? 10 : q.scaleMax ?? 5;
3901
+ const values = Array.from({ length: max - min + 1 }, (_, i) => min + i);
3902
+ return /* @__PURE__ */ jsxs7(View8, { children: [
3903
+ /* @__PURE__ */ jsx8(View8, { style: styles8.scale, accessibilityRole: "radiogroup", accessibilityLabel: q.title, children: values.map((n) => /* @__PURE__ */ jsx8(
3904
+ Pressable6,
3905
+ {
3906
+ accessibilityRole: "radio",
3907
+ accessibilityState: { selected: value === n },
3908
+ accessibilityLabel: String(n),
3909
+ onPress: () => onChange(n),
3910
+ style: [...chip(value === n), styles8.scaleItem],
3911
+ children: /* @__PURE__ */ jsx8(Text7, { style: chipText(value === n), children: n })
3912
+ },
3913
+ n
3914
+ )) }),
3915
+ q.lowLabel || q.highLabel ? /* @__PURE__ */ jsxs7(View8, { style: styles8.labels, children: [
3916
+ /* @__PURE__ */ jsx8(Text7, { style: [styles8.small, { color: p.muted }], children: q.lowLabel }),
3917
+ /* @__PURE__ */ jsx8(Text7, { style: [styles8.small, { color: p.muted }], children: q.highLabel })
3918
+ ] }) : null
3919
+ ] });
3920
+ }
3921
+ case "rating": {
3922
+ const max = q.scaleMax ?? 5;
3923
+ return /* @__PURE__ */ jsx8(View8, { style: styles8.stars, accessibilityRole: "radiogroup", accessibilityLabel: q.title, children: Array.from({ length: max }, (_, i) => i + 1).map((n) => /* @__PURE__ */ jsx8(
3924
+ Pressable6,
3925
+ {
3926
+ accessibilityRole: "radio",
3927
+ accessibilityLabel: `${n} of ${max}`,
3928
+ accessibilityState: { selected: value === n },
3929
+ onPress: () => onChange(n),
3930
+ hitSlop: 4,
3931
+ children: /* @__PURE__ */ jsx8(
3932
+ Text7,
3933
+ {
3934
+ style: [
3935
+ styles8.star,
3936
+ { color: typeof value === "number" && n <= value ? p.primary : p.border }
3937
+ ],
3938
+ children: "\u2605"
3939
+ }
3940
+ )
3941
+ },
3942
+ n
3943
+ )) });
3944
+ }
3945
+ case "thumbs":
3946
+ return /* @__PURE__ */ jsx8(View8, { style: styles8.row, accessibilityRole: "radiogroup", children: [
3947
+ [true, "\u{1F44D}", "Thumbs up"],
3948
+ [false, "\u{1F44E}", "Thumbs down"]
3949
+ ].map(([val, icon, label]) => /* @__PURE__ */ jsx8(
3950
+ Pressable6,
3951
+ {
3952
+ accessibilityRole: "radio",
3953
+ accessibilityLabel: label,
3954
+ accessibilityState: { selected: value === val },
3955
+ onPress: () => onChange(val),
3956
+ style: [...chip(value === val), styles8.thumb],
3957
+ children: /* @__PURE__ */ jsx8(Text7, { style: styles8.thumbText, children: icon })
3958
+ },
3959
+ String(val)
3960
+ )) });
3961
+ case "single_choice":
3962
+ case "multiple_choice": {
3963
+ const multi = q.type === "multiple_choice";
3964
+ const selected = new Set(
3965
+ multi ? value ?? [] : value !== void 0 ? [String(value)] : []
3966
+ );
3967
+ return /* @__PURE__ */ jsx8(View8, { style: styles8.choices, children: q.options.map((o) => {
3968
+ const on = selected.has(o.id);
3969
+ return /* @__PURE__ */ jsxs7(
3970
+ Pressable6,
3971
+ {
3972
+ accessibilityRole: multi ? "checkbox" : "radio",
3973
+ accessibilityState: { checked: on },
3974
+ accessibilityLabel: o.label,
3975
+ onPress: () => {
3976
+ if (!multi) return onChange(o.id);
3977
+ const next = new Set(selected);
3978
+ if (on) next.delete(o.id);
3979
+ else next.add(o.id);
3980
+ onChange(next.size ? Array.from(next) : void 0);
3981
+ },
3982
+ style: [
3983
+ styles8.choice,
3984
+ { borderColor: on ? p.primary : p.border, borderRadius: Math.min(10, p.radius) }
3985
+ ],
3986
+ children: [
3987
+ /* @__PURE__ */ jsx8(
3988
+ View8,
3989
+ {
3990
+ style: [
3991
+ styles8.dot,
3992
+ multi && styles8.box,
3993
+ {
3994
+ borderColor: on ? p.primary : p.border,
3995
+ backgroundColor: on ? p.primary : "transparent"
3996
+ }
3997
+ ]
3998
+ }
3999
+ ),
4000
+ /* @__PURE__ */ jsx8(Text7, { style: [styles8.choiceText, { color: p.text }], children: o.label })
4001
+ ]
4002
+ },
4003
+ o.id
4004
+ );
4005
+ }) });
4006
+ }
4007
+ case "text":
4008
+ default:
4009
+ return /* @__PURE__ */ jsx8(
4010
+ TextInput3,
4011
+ {
4012
+ accessibilityLabel: q.title,
4013
+ multiline: true,
4014
+ value: typeof value === "string" ? value : "",
4015
+ onChangeText: (t) => onChange(t ? t.slice(0, 5e3) : void 0),
4016
+ placeholder: q.placeholder || "",
4017
+ placeholderTextColor: p.muted,
4018
+ style: [
4019
+ styles8.input,
4020
+ {
4021
+ color: p.text,
4022
+ borderColor: p.border,
4023
+ borderRadius: Math.min(10, p.radius),
4024
+ backgroundColor: p.surface
4025
+ }
4026
+ ]
4027
+ }
4028
+ );
4029
+ }
4030
+ }
4031
+ var styles8 = StyleSheet8.create({
4032
+ fill: { flex: 1 },
4033
+ center: { alignItems: "center", justifyContent: "center" },
4034
+ bottom: { alignItems: "center", justifyContent: "flex-end" },
4035
+ inner: { padding: 16, gap: 14 },
4036
+ title: { fontSize: 17, fontWeight: "700" },
4037
+ end: { alignSelf: "flex-end" },
4038
+ question: { gap: 8 },
4039
+ head: { flexDirection: "row", gap: 8, alignItems: "flex-start" },
4040
+ qTitle: { flex: 1, fontSize: 15, fontWeight: "600", lineHeight: 20 },
4041
+ desc: { fontSize: 13 },
4042
+ scale: { flexDirection: "row", flexWrap: "wrap", gap: 4 },
4043
+ scaleItem: { flexGrow: 1, flexBasis: 26 },
4044
+ chip: {
4045
+ borderWidth: 1,
4046
+ minHeight: 36,
4047
+ alignItems: "center",
4048
+ justifyContent: "center",
4049
+ paddingHorizontal: 4
4050
+ },
4051
+ chipText: { fontSize: 14, fontWeight: "600" },
4052
+ labels: { flexDirection: "row", justifyContent: "space-between", marginTop: 4 },
4053
+ small: { fontSize: 12 },
4054
+ stars: { flexDirection: "row", gap: 6 },
4055
+ star: { fontSize: 30 },
4056
+ row: { flexDirection: "row", gap: 10 },
4057
+ thumb: { width: 56, height: 44 },
4058
+ thumbText: { fontSize: 22 },
4059
+ choices: { gap: 8 },
4060
+ choice: {
4061
+ flexDirection: "row",
4062
+ alignItems: "center",
4063
+ gap: 10,
4064
+ borderWidth: 1,
4065
+ paddingVertical: 10,
4066
+ paddingHorizontal: 12
4067
+ },
4068
+ dot: { width: 18, height: 18, borderRadius: 9, borderWidth: 2 },
4069
+ box: { borderRadius: 4 },
4070
+ choiceText: { fontSize: 15, flex: 1 },
4071
+ input: { minHeight: 80, borderWidth: 1, padding: 10, fontSize: 15, textAlignVertical: "top" },
4072
+ error: { color: "#dc2626", fontSize: 13 }
4073
+ });
4074
+
4075
+ // src/ui/theme.ts
4076
+ var DARK = { background: "#18181b", text: "#f4f4f5", muted: "#a1a1aa", border: "#3f3f46", surface: "#27272a" };
4077
+ function luminance(hex) {
4078
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex);
4079
+ if (!m) return 1;
4080
+ const n = parseInt(m[1], 16);
4081
+ const ch = [n >> 16 & 255, n >> 8 & 255, n & 255].map((c) => {
4082
+ const s = c / 255;
4083
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
4084
+ });
4085
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
4086
+ }
4087
+ function mix(hex, alpha) {
4088
+ const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255).toString(16).padStart(2, "0");
4089
+ return /^#[0-9a-f]{6}$/i.test(hex) ? `${hex}${a}` : hex;
4090
+ }
4091
+ function nativeFont(stack) {
4092
+ const first = stack?.split(",")[0]?.trim().replace(/^["']|["']$/g, "");
4093
+ if (!first || /^(system-ui|-apple-system|sans-serif|serif|monospace|inter|ui-sans-serif)$/i.test(first)) return void 0;
4094
+ return first;
4095
+ }
4096
+ function palette(theme, systemScheme, explicit) {
4097
+ const primary = theme.primaryColor ?? "#4f46e5";
4098
+ const wantsDark = theme.dark === "dark" || theme.dark === "auto" && systemScheme === "dark";
4099
+ const dark = wantsDark && !explicit?.backgroundColor;
4100
+ const background = dark ? DARK.background : theme.backgroundColor ?? "#ffffff";
4101
+ const text = dark ? DARK.text : theme.textColor ?? "#111827";
4102
+ return {
4103
+ primary,
4104
+ onPrimary: luminance(primary) > 0.45 ? "#111827" : "#ffffff",
4105
+ background,
4106
+ text,
4107
+ muted: dark ? DARK.muted : mix(text, 0.65),
4108
+ border: dark ? DARK.border : mix(text, 0.12),
4109
+ surface: dark ? DARK.surface : mix(text, 0.05),
4110
+ radius: theme.borderRadius ?? 12,
4111
+ overlay: `rgba(15, 23, 42, ${theme.overlayOpacity ?? 0.55})`,
4112
+ fontFamily: nativeFont(theme.fontFamily)
4113
+ };
4114
+ }
4115
+
4116
+ // src/ui/Overlay.tsx
4117
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
4118
+ function defaultInsets(width, height) {
4119
+ if (Platform5.OS === "ios") {
4120
+ const notch = Math.max(width, height) >= 812 && !Platform5.isPad;
4121
+ const landscape = width > height;
4122
+ return { top: landscape ? 0 : notch ? 50 : 20, bottom: notch ? landscape ? 21 : 34 : 0, left: landscape && notch ? 44 : 0, right: landscape && notch ? 44 : 0 };
4123
+ }
4124
+ return { top: StatusBar.currentHeight ?? 24, bottom: 16, left: 0, right: 0 };
4125
+ }
4126
+ function GuidelyOverlay({ client, insets: insetsProp, launcherOffset }) {
4127
+ const ui = useSyncExternalStore2(
4128
+ useCallback2((cb) => client.subscribeUi(cb), [client]),
4129
+ () => client.getUiState(),
4130
+ () => client.getUiState()
4131
+ );
4132
+ const { width, height } = useWindowDimensions();
4133
+ const scheme = useColorScheme();
4134
+ const ref = useRef3(null);
4135
+ const [origin, setOrigin] = useState5({ x: 0, y: 0 });
4136
+ useEffect5(() => {
4137
+ client.targets.setViewport(width, height);
4138
+ }, [client, width, height]);
4139
+ const measureOrigin = useCallback2(() => {
4140
+ ref.current?.measureInWindow?.((x, y) => {
4141
+ if (Number.isFinite(x) && Number.isFinite(y)) setOrigin((o) => o.x === x && o.y === y ? o : { x, y });
4142
+ });
4143
+ }, []);
4144
+ const insets = { ...defaultInsets(width, height), ...insetsProp };
4145
+ const geometry = { origin, viewport: { width, height }, insets };
4146
+ const basePalette = palette(ui.theme, scheme);
4147
+ const messenger = ui.messenger ? client.getMessenger() : null;
4148
+ const showRcLauncher = !messenger && !!client.options.showResourceCenterLauncher && !!client.getResourceCenter();
4149
+ const messengerLeft = messenger?.getSnapshot().config.launcher.position === "bottom-left";
4150
+ const checklistOnSameSide = ui.checklists.some((c) => c.guide.config.checklist?.position === "bottom-left" === messengerLeft) && client.options.showChecklistLauncher !== false;
4151
+ return /* @__PURE__ */ jsxs8(View9, { ref, collapsable: false, pointerEvents: "box-none", style: StyleSheet9.absoluteFill, onLayout: measureOrigin, children: [
4152
+ ui.checklists.map((c) => /* @__PURE__ */ jsx9(Checklist, { view: c, client, palette: palette(client.themeFor(c.guide), scheme), geometry, showLauncher: client.options.showChecklistLauncher !== false, launcherBottom: launcherOffset }, c.guide.id)),
4153
+ showRcLauncher ? /* @__PURE__ */ jsx9(
4154
+ Pressable7,
4155
+ {
4156
+ accessibilityRole: "button",
4157
+ accessibilityLabel: client.getResourceCenter()?.launcher.label || "Help",
4158
+ onPress: () => client.openResourceCenter(),
4159
+ style: [styles9.rc, client.getResourceCenter()?.launcher.position === "bottom-left" ? { left: insets.left + 16 } : { right: insets.right + 16 }, { bottom: insets.bottom + launcherOffset + (ui.checklists.length ? 56 : 0), backgroundColor: basePalette.primary }],
4160
+ children: /* @__PURE__ */ jsx9(Text8, { style: [styles9.rcText, { color: basePalette.onPrimary }], children: "?" })
4161
+ }
4162
+ ) : null,
4163
+ messenger && client.options.showMessengerLauncher !== false ? /* @__PURE__ */ jsx9(MessengerLauncher, { store: messenger, palette: basePalette, style: [messengerLeft ? { left: insets.left + 16 } : { right: insets.right + 16 }, { bottom: insets.bottom + launcherOffset + (checklistOnSameSide ? 56 : 0) }] }) : null,
4164
+ ui.survey ? /* @__PURE__ */ jsx9(Survey, { view: ui.survey, client, palette: palette(client.themeFor(ui.survey.guide), scheme), geometry }) : null,
4165
+ ui.steps.map((s) => /* @__PURE__ */ jsx9(StepLayer, { view: s, client, palette: palette(client.themeFor(s.guide), scheme, s.guide.config.appearance), geometry }, s.key)),
4166
+ messenger ? /* @__PURE__ */ jsx9(MessengerSheet, { store: messenger, client, palette: basePalette, topInset: insets.top, bottomInset: insets.bottom }) : /* @__PURE__ */ jsx9(ResourceCenter, { client, palette: basePalette, open: ui.resourceCenterOpen, topInset: insets.top, bottomInset: insets.bottom })
4167
+ ] });
4168
+ }
4169
+ var styles9 = StyleSheet9.create({
4170
+ rc: { position: "absolute", width: 48, height: 48, borderRadius: 24, alignItems: "center", justifyContent: "center", elevation: 8, shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 10, shadowOffset: { width: 0, height: 4 } },
4171
+ rcText: { fontSize: 22, fontWeight: "800" }
4172
+ });
4173
+
4174
+ // src/ui/Target.tsx
4175
+ import { createContext, useCallback as useCallback3, useContext, useEffect as useEffect6, useRef as useRef4 } from "react";
4176
+ import { View as View10 } from "react-native";
4177
+ import { jsx as jsx10 } from "react/jsx-runtime";
4178
+ var GuidelyContext = createContext(null);
4179
+ var GuidelyScreenContext = createContext(null);
4180
+ function GuidelyScreenScope({ name, children }) {
4181
+ return /* @__PURE__ */ jsx10(GuidelyScreenContext.Provider, { value: name, children });
4182
+ }
4183
+ function guidelyScreenLayout({ route, children }) {
4184
+ return /* @__PURE__ */ jsx10(GuidelyScreenScope, { name: route.name, children });
4185
+ }
4186
+ var TAP_SLOP = 10;
4187
+ function useGuidelyTarget(id) {
4188
+ const client = useContext(GuidelyContext);
4189
+ const screen = useContext(GuidelyScreenContext);
4190
+ const ref = useRef4(null);
4191
+ const start = useRef4(null);
4192
+ useEffect6(() => {
4193
+ if (!client || !id) return;
4194
+ return client.targets.register(id, {
4195
+ screen,
4196
+ measure: () => new Promise((resolve) => {
4197
+ const node = ref.current;
4198
+ if (!node || typeof node.measureInWindow !== "function") return resolve(null);
4199
+ node.measureInWindow((x, y, width, height) => resolve(Number.isFinite(x) ? { x, y, width, height } : null));
4200
+ })
4201
+ });
4202
+ }, [client, id, screen]);
4203
+ const onLayout = useCallback3(() => client?.targets.changed(), [client]);
4204
+ const onTouchStart = useCallback3((e) => {
4205
+ start.current = { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY };
4206
+ }, []);
4207
+ const onTouchEnd = useCallback3(
4208
+ (e) => {
4209
+ const s = start.current;
4210
+ start.current = null;
4211
+ if (s && Math.abs(e.nativeEvent.pageX - s.x) < TAP_SLOP && Math.abs(e.nativeEvent.pageY - s.y) < TAP_SLOP) client?.targets.tapped(id);
4212
+ },
4213
+ [client, id]
4214
+ );
4215
+ return { ref, collapsable: false, onLayout, onTouchStart, onTouchEnd };
4216
+ }
4217
+ function GuidelyTarget({ id, children, onLayout, onTouchStart, onTouchEnd, ...rest }) {
4218
+ const t = useGuidelyTarget(id);
4219
+ return /* @__PURE__ */ jsx10(
4220
+ View10,
4221
+ {
4222
+ ...rest,
4223
+ ref: t.ref,
4224
+ collapsable: false,
4225
+ onLayout: (e) => {
4226
+ t.onLayout();
4227
+ onLayout?.(e);
4228
+ },
4229
+ onTouchStart: (e) => {
4230
+ t.onTouchStart(e);
4231
+ onTouchStart?.(e);
4232
+ },
4233
+ onTouchEnd: (e) => {
4234
+ t.onTouchEnd(e);
4235
+ onTouchEnd?.(e);
4236
+ },
4237
+ children
4238
+ }
4239
+ );
4240
+ }
4241
+
4242
+ // src/index.tsx
4243
+ import { jsx as jsx11, jsxs as jsxs9 } from "react/jsx-runtime";
4244
+ function deviceLocale() {
4245
+ try {
4246
+ return Intl.DateTimeFormat().resolvedOptions().locale;
4247
+ } catch {
4248
+ return void 0;
4249
+ }
4250
+ }
4251
+ function defaultBridge(appVersion) {
4252
+ const { width, height } = Dimensions.get("window");
4253
+ return {
4254
+ device: {
4255
+ platform: Platform6.OS === "android" ? "android" : "ios",
4256
+ deviceType: Platform6.OS === "ios" && Platform6.isPad || Math.min(width, height) >= 600 ? "tablet" : "mobile",
4257
+ locale: deviceLocale(),
4258
+ appVersion
4259
+ },
4260
+ openExternal: (url) => void Linking.openURL(url).catch(() => {
4261
+ })
4262
+ };
4263
+ }
4264
+ function GuidelyProvider({ children, user, insets, launcherOffset = 72, ...options }) {
4265
+ const [client, setClient] = useState6(null);
4266
+ const optionsRef = useRef5(options);
4267
+ optionsRef.current = options;
4268
+ const { projectId, environment, apiUrl, storage } = options;
4269
+ useEffect7(() => {
4270
+ if (!projectId) return;
4271
+ let c;
4272
+ try {
4273
+ const latest = optionsRef.current;
4274
+ c = new GuidelyClient(
4275
+ { ...latest, projectId, environment, apiUrl, storage, onNavigate: (url) => optionsRef.current.onNavigate?.(url), onError: (err) => optionsRef.current.onError?.(err) },
4276
+ defaultBridge(latest.appVersion)
4277
+ );
4278
+ } catch (err) {
4279
+ optionsRef.current.onError?.(err instanceof Error ? err : new Error(String(err)));
4280
+ return;
4281
+ }
4282
+ setClient(c);
4283
+ void c.init();
4284
+ const sub = AppState.addEventListener("change", (s) => c.setAppState(s));
4285
+ return () => {
4286
+ sub.remove();
4287
+ c.destroy();
4288
+ setClient((cur) => cur === c ? null : cur);
4289
+ };
4290
+ }, [projectId, environment, apiUrl, storage]);
4291
+ const userKey = user ? JSON.stringify([user.id, user.traits ?? null, user.account ?? null]) : "";
4292
+ const hadUser = useRef5(false);
4293
+ useEffect7(() => {
4294
+ if (!client) return;
4295
+ if (user && user.id) {
4296
+ hadUser.current = true;
4297
+ void client.identify(user.id, user.traits, user.account);
4298
+ } else if (hadUser.current) {
4299
+ hadUser.current = false;
4300
+ client.reset();
4301
+ }
4302
+ }, [client, userKey]);
4303
+ return /* @__PURE__ */ jsxs9(GuidelyContext.Provider, { value: client, children: [
4304
+ children,
4305
+ client ? /* @__PURE__ */ jsx11(GuidelyOverlay, { client, insets, launcherOffset }) : null
4306
+ ] });
4307
+ }
4308
+ var NOOP_RESULTS = {
4309
+ init: Promise.resolve(),
4310
+ ready: Promise.resolve(),
4311
+ identify: Promise.resolve(),
4312
+ setAccount: Promise.resolve(),
4313
+ updateTraits: Promise.resolve(),
4314
+ refresh: Promise.resolve(),
4315
+ startGuide: false,
4316
+ getActiveGuide: null,
4317
+ getGuides: [],
4318
+ getChecklistProgress: { completedTaskIds: [], completedAt: null, dismissedAt: null },
4319
+ getGuideState: { impressions: 0 },
4320
+ getMessenger: null,
4321
+ isGuideCompleted: false,
4322
+ isClientReady: false,
4323
+ getScreen: "",
4324
+ on: () => {
4325
+ },
4326
+ subscribeUi: () => () => {
4327
+ },
4328
+ getState: { ready: false, destroyed: false }
4329
+ };
4330
+ var noopClient = new Proxy({}, {
4331
+ get(_target, prop) {
4332
+ if (prop === "options") return { projectId: "" };
4333
+ if (typeof prop !== "string") return void 0;
4334
+ return () => NOOP_RESULTS[prop];
4335
+ }
4336
+ });
4337
+ function useGuidely() {
4338
+ return useContext2(GuidelyContext) ?? noopClient;
4339
+ }
4340
+ function useGuidelyReady() {
4341
+ const client = useContext2(GuidelyContext);
4342
+ const [ready, setReady] = useState6(() => !!client?.isClientReady());
4343
+ useEffect7(() => {
4344
+ if (!client) return setReady(false);
4345
+ setReady(client.isClientReady());
4346
+ const off = client.on("ready", () => setReady(true));
4347
+ void client.ready().then(() => setReady(client.isClientReady()));
4348
+ return off;
4349
+ }, [client]);
4350
+ return ready;
4351
+ }
4352
+ function useGuidelyIdentify(userId, traits, account) {
4353
+ const client = useContext2(GuidelyContext);
4354
+ const key = JSON.stringify([userId ?? null, traits ?? null, account ?? null]);
4355
+ useEffect7(() => {
4356
+ if (client && userId) void client.identify(userId, traits, account);
4357
+ }, [client, key]);
4358
+ }
4359
+ function useGuidelyEvent(event, handler) {
4360
+ const client = useContext2(GuidelyContext);
4361
+ const ref = useRef5(handler);
4362
+ ref.current = handler;
4363
+ useEffect7(() => {
4364
+ if (!client) return;
4365
+ return client.on(event, ((...args) => ref.current(...args)));
4366
+ }, [client, event]);
4367
+ }
4368
+ function useGuidelyScreen(name, properties) {
4369
+ const client = useContext2(GuidelyContext);
4370
+ useEffect7(() => {
4371
+ if (client && name) client.screen(name, properties);
4372
+ }, [client, name]);
4373
+ }
4374
+ function useGuidelyNavigation(navigationRef, options = {}) {
4375
+ const client = useContext2(GuidelyContext);
4376
+ const nameRef = useRef5(options.screenName);
4377
+ nameRef.current = options.screenName;
4378
+ const report = () => {
4379
+ const route = navigationRef.getCurrentRoute?.();
4380
+ if (client && route?.name) client.screen(nameRef.current ? nameRef.current(route) : route.name);
4381
+ };
4382
+ useEffect7(report, [client]);
4383
+ return { onReady: report, onStateChange: report };
4384
+ }
4385
+ function useUiSelector(select, fallback) {
4386
+ const client = useContext2(GuidelyContext);
4387
+ const selectRef = useRef5(select);
4388
+ selectRef.current = select;
4389
+ const [value, setValue] = useState6(() => client ? select(client) : fallback);
4390
+ useEffect7(() => {
4391
+ if (!client) return;
4392
+ let last = JSON.stringify(selectRef.current(client));
4393
+ setValue(selectRef.current(client));
4394
+ const update = () => {
4395
+ const next = selectRef.current(client);
4396
+ const key = JSON.stringify(next);
4397
+ if (key !== last) {
4398
+ last = key;
4399
+ setValue(next);
4400
+ }
4401
+ };
4402
+ const offs = [client.subscribeUi(update), client.on("checklist:task", update), client.on("identify", update), client.on("guide:complete", update), client.on("guide:dismiss", update), client.on("ready", update)];
4403
+ return () => offs.forEach((off) => off());
4404
+ }, [client]);
4405
+ return value;
4406
+ }
4407
+ function useChecklistProgress(idOrKey) {
4408
+ return useUiSelector((c) => c.getChecklistProgress(idOrKey), { completedTaskIds: [], completedAt: null, dismissedAt: null });
4409
+ }
4410
+ function useGuideState(idOrKey) {
4411
+ return useUiSelector(
4412
+ (c) => {
4413
+ const s = c.getGuideState(idOrKey);
4414
+ return { impressions: s.impressions, completed: !!s.completedAt, dismissed: !!s.dismissedAt };
4415
+ },
4416
+ { impressions: 0, completed: false, dismissed: false }
4417
+ );
4418
+ }
4419
+ function useActiveGuide() {
4420
+ return useUiSelector((c) => c.getActiveGuide(), null);
4421
+ }
4422
+ export {
4423
+ DEFAULT_API_URL,
4424
+ GuidelyClient,
4425
+ GuidelyOverlay,
4426
+ GuidelyProvider,
4427
+ GuidelyScreenScope,
4428
+ GuidelyTarget,
4429
+ Messenger,
4430
+ SDK_NAME,
4431
+ SDK_VERSION,
4432
+ TargetRegistry,
4433
+ createSseParser,
4434
+ guidelyScreenLayout,
4435
+ htmlToText,
4436
+ noopClient,
4437
+ parseHtml,
4438
+ positionCard,
4439
+ targetIdsFor,
4440
+ useActiveGuide,
4441
+ useChecklistProgress,
4442
+ useGuideState,
4443
+ useGuidely,
4444
+ useGuidelyEvent,
4445
+ useGuidelyIdentify,
4446
+ useGuidelyNavigation,
4447
+ useGuidelyReady,
4448
+ useGuidelyScreen,
4449
+ useGuidelyTarget
4450
+ };
4451
+ //# sourceMappingURL=index.js.map