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