@guidelytech/react-native 0.0.0-stage → 0.5.0

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