@mrrocket/sdk 0.10.0 → 0.11.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/pick.js ADDED
@@ -0,0 +1,117 @@
1
+ // src/pick.ts
2
+ var PICK_PARAM = "ck_pick";
3
+ var PICK_ORIGIN_PARAM = "ck_origin";
4
+ var esc = (s) => typeof CSS !== "undefined" && CSS.escape ? CSS.escape(s) : s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
5
+ var stableClasses = (el) => [...el.classList].filter((c) => c.length < 30 && !/\d{2,}|[_-][a-z0-9]{5,}$|^(is|has)-|hover|active|focus|open|selected|:|\[/.test(c)).slice(0, 2);
6
+ var pickSelector = (el) => {
7
+ const doc = el.ownerDocument;
8
+ const unique = (sel) => {
9
+ try {
10
+ return doc.querySelectorAll(sel).length === 1;
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+ const named = el.getAttribute("data-ck-target");
16
+ if (named) return `[data-ck-target="${named.replace(/"/g, '\\"')}"]`;
17
+ if (el.id && !/\d{3,}/.test(el.id) && unique(`#${esc(el.id)}`)) return `#${esc(el.id)}`;
18
+ const parts = [];
19
+ let node = el;
20
+ while (node && node !== doc.documentElement && parts.length < 6) {
21
+ if (node !== el && node.id && !/\d{3,}/.test(node.id)) {
22
+ parts.unshift(`#${esc(node.id)}`);
23
+ break;
24
+ }
25
+ const tag = node.tagName.toLowerCase();
26
+ const cls = stableClasses(node).map((c) => `.${esc(c)}`).join("");
27
+ const parent = node.parentElement;
28
+ const same = parent ? [...parent.children].filter((c) => c.tagName === node.tagName) : [];
29
+ const nth = same.length > 1 ? `:nth-of-type(${same.indexOf(node) + 1})` : "";
30
+ parts.unshift(`${tag}${cls}${nth}`);
31
+ const sel = parts.join(" > ");
32
+ if (unique(sel)) return sel;
33
+ node = parent;
34
+ }
35
+ return parts.join(" > ");
36
+ };
37
+ var startPicker = () => {
38
+ if (typeof window === "undefined" || window.top === window) return null;
39
+ const params = new URLSearchParams(window.location.search);
40
+ if (params.get(PICK_PARAM) !== "1") return null;
41
+ let origin;
42
+ try {
43
+ origin = new URL(params.get(PICK_ORIGIN_PARAM) ?? "").origin;
44
+ } catch {
45
+ return null;
46
+ }
47
+ if (!/^https?:\/\//.test(origin)) return null;
48
+ const send = (data) => window.parent.postMessage(data, origin);
49
+ const box = document.createElement("div");
50
+ box.setAttribute("aria-hidden", "true");
51
+ Object.assign(box.style, {
52
+ position: "fixed",
53
+ zIndex: "2147483647",
54
+ pointerEvents: "none",
55
+ border: "2px solid #0ea5e9",
56
+ background: "rgba(14,165,233,.12)",
57
+ borderRadius: "6px",
58
+ transition: "all 60ms linear",
59
+ display: "none"
60
+ });
61
+ const tag = document.createElement("div");
62
+ Object.assign(tag.style, {
63
+ position: "absolute",
64
+ left: "-2px",
65
+ bottom: "calc(100% + 4px)",
66
+ font: "600 11px/1.4 system-ui, sans-serif",
67
+ color: "#fff",
68
+ background: "#0c4a6e",
69
+ padding: "2px 6px",
70
+ borderRadius: "4px",
71
+ whiteSpace: "nowrap",
72
+ maxWidth: "360px",
73
+ overflow: "hidden",
74
+ textOverflow: "ellipsis"
75
+ });
76
+ box.appendChild(tag);
77
+ document.body.appendChild(box);
78
+ let current = null;
79
+ const show = (el) => {
80
+ current = el && el !== box && !box.contains(el) ? el : null;
81
+ if (!current) {
82
+ box.style.display = "none";
83
+ return;
84
+ }
85
+ const r = current.getBoundingClientRect();
86
+ Object.assign(box.style, { display: "block", left: `${r.left - 2}px`, top: `${r.top - 2}px`, width: `${r.width + 4}px`, height: `${r.height + 4}px` });
87
+ tag.textContent = pickSelector(current);
88
+ };
89
+ const move = (e) => show(document.elementFromPoint(e.clientX, e.clientY));
90
+ const click = (e) => {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ const el = document.elementFromPoint(e.clientX, e.clientY) ?? current;
94
+ if (!el || el === box) return;
95
+ send({ type: "mrrocket:picked", selector: pickSelector(el), text: (el.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80) });
96
+ };
97
+ const key = (e) => {
98
+ if (e.key === "Escape") send({ type: "mrrocket:pick-cancel" });
99
+ };
100
+ window.addEventListener("mousemove", move, true);
101
+ window.addEventListener("click", click, true);
102
+ window.addEventListener("keydown", key, true);
103
+ send({ type: "mrrocket:pick-ready" });
104
+ return () => {
105
+ window.removeEventListener("mousemove", move, true);
106
+ window.removeEventListener("click", click, true);
107
+ window.removeEventListener("keydown", key, true);
108
+ box.remove();
109
+ };
110
+ };
111
+ export {
112
+ PICK_ORIGIN_PARAM,
113
+ PICK_PARAM,
114
+ pickSelector,
115
+ startPicker
116
+ };
117
+ //# sourceMappingURL=pick.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/pick.ts"],"sourcesContent":["/**\n * \"Pick on your site\" (0.11.0). MRRocket's onboarding editor opens the site in a frame with\n * `?ck_embed=1&ck_pick=1&ck_origin=<the editor's origin>`: nothing is tracked (ck_embed), the element under the pointer\n * gets an outline with its selector, and a click picks it instead of doing what it does on the page. The selector goes to\n * the editor by postMessage, to that origin only; Esc tells it the person stopped. Only inside a frame, only with the\n * parameter: a normal visit never sees any of this.\n *\n * Messages to the editor: `{ type: \"mrrocket:pick-ready\" }` once listening, `{ type: \"mrrocket:picked\", selector, text }`\n * on a click, `{ type: \"mrrocket:pick-cancel\" }` on Esc.\n */\nexport const PICK_PARAM = \"ck_pick\";\nexport const PICK_ORIGIN_PARAM = \"ck_origin\";\n\nconst esc = (s: string) => (typeof CSS !== \"undefined\" && CSS.escape ? CSS.escape(s) : s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\\\${c}`));\n\n/** Classes worth naming: no state classes, no generated hashes or numbers (css-modules, tailwind arbitrary values). */\nconst stableClasses = (el: Element) =>\n [...el.classList].filter((c) => c.length < 30 && !/\\d{2,}|[_-][a-z0-9]{5,}$|^(is|has)-|hover|active|focus|open|selected|:|\\[/.test(c)).slice(0, 2);\n\n/** A short CSS selector that finds this element, and only it, on the page. */\nexport const pickSelector = (el: Element): string => {\n const doc = el.ownerDocument;\n const unique = (sel: string) => {\n try {\n return doc.querySelectorAll(sel).length === 1;\n } catch {\n return false;\n }\n };\n const named = el.getAttribute(\"data-ck-target\");\n if (named) return `[data-ck-target=\"${named.replace(/\"/g, '\\\\\"')}\"]`;\n if (el.id && !/\\d{3,}/.test(el.id) && unique(`#${esc(el.id)}`)) return `#${esc(el.id)}`;\n const parts: string[] = [];\n let node: Element | null = el;\n while (node && node !== doc.documentElement && parts.length < 6) {\n if (node !== el && node.id && !/\\d{3,}/.test(node.id)) {\n parts.unshift(`#${esc(node.id)}`);\n break;\n }\n const tag = node.tagName.toLowerCase();\n const cls = stableClasses(node)\n .map((c) => `.${esc(c)}`)\n .join(\"\");\n const parent: Element | null = node.parentElement;\n const same = parent ? [...parent.children].filter((c) => c.tagName === node!.tagName) : [];\n const nth = same.length > 1 ? `:nth-of-type(${same.indexOf(node) + 1})` : \"\";\n parts.unshift(`${tag}${cls}${nth}`);\n const sel = parts.join(\" > \");\n if (unique(sel)) return sel;\n node = parent;\n }\n return parts.join(\" > \");\n};\n\n/** Starts picking when the page was opened for it; returns how to stop, or null when it was not. */\nexport const startPicker = (): (() => void) | null => {\n if (typeof window === \"undefined\" || window.top === window) return null;\n const params = new URLSearchParams(window.location.search);\n if (params.get(PICK_PARAM) !== \"1\") return null;\n let origin: string;\n try {\n origin = new URL(params.get(PICK_ORIGIN_PARAM) ?? \"\").origin;\n } catch {\n return null;\n }\n if (!/^https?:\\/\\//.test(origin)) return null;\n const send = (data: Record<string, unknown>) => window.parent.postMessage(data, origin);\n\n const box = document.createElement(\"div\");\n box.setAttribute(\"aria-hidden\", \"true\");\n Object.assign(box.style, {\n position: \"fixed\",\n zIndex: \"2147483647\",\n pointerEvents: \"none\",\n border: \"2px solid #0ea5e9\",\n background: \"rgba(14,165,233,.12)\",\n borderRadius: \"6px\",\n transition: \"all 60ms linear\",\n display: \"none\",\n });\n const tag = document.createElement(\"div\");\n Object.assign(tag.style, {\n position: \"absolute\",\n left: \"-2px\",\n bottom: \"calc(100% + 4px)\",\n font: \"600 11px/1.4 system-ui, sans-serif\",\n color: \"#fff\",\n background: \"#0c4a6e\",\n padding: \"2px 6px\",\n borderRadius: \"4px\",\n whiteSpace: \"nowrap\",\n maxWidth: \"360px\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n });\n box.appendChild(tag);\n document.body.appendChild(box);\n\n let current: Element | null = null;\n const show = (el: Element | null) => {\n current = el && el !== box && !box.contains(el) ? el : null;\n if (!current) {\n box.style.display = \"none\";\n return;\n }\n const r = current.getBoundingClientRect();\n Object.assign(box.style, { display: \"block\", left: `${r.left - 2}px`, top: `${r.top - 2}px`, width: `${r.width + 4}px`, height: `${r.height + 4}px` });\n tag.textContent = pickSelector(current);\n };\n const move = (e: MouseEvent) => show(document.elementFromPoint(e.clientX, e.clientY));\n const click = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n const el = document.elementFromPoint(e.clientX, e.clientY) ?? current;\n if (!el || el === box) return;\n send({ type: \"mrrocket:picked\", selector: pickSelector(el), text: (el.textContent ?? \"\").trim().replace(/\\s+/g, \" \").slice(0, 80) });\n };\n const key = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") send({ type: \"mrrocket:pick-cancel\" });\n };\n // Capturing, so the page's own handlers (links, buttons) never see the picking click.\n window.addEventListener(\"mousemove\", move, true);\n window.addEventListener(\"click\", click, true);\n window.addEventListener(\"keydown\", key, true);\n send({ type: \"mrrocket:pick-ready\" });\n return () => {\n window.removeEventListener(\"mousemove\", move, true);\n window.removeEventListener(\"click\", click, true);\n window.removeEventListener(\"keydown\", key, true);\n box.remove();\n };\n};\n"],"mappings":"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","names":[]}
@@ -224,7 +224,9 @@ type Api = ReturnType<typeof createApi>;
224
224
  * `finished` with `outcome: "saved"` into a save and with `outcome: "cancelled"` into a cancellation on the Churn page.
225
225
  * MRRocket never changes a subscription: an accepted offer goes to the app's `onSave`, a confirmed cancel to its
226
226
  * `onCancel`, and only after they succeed is the outcome recorded.
227
- * - `$onboarding` `{flow, step?, action: "started" | "shown" | "completed" | "dismissed"}`.
227
+ * - `$onboarding` `{flow, step?, action: "started" | "shown" | "completed" | "dismissed"}`; since 0.11.0 a wait records
228
+ * "shown" when the person reaches it and "completed" when it is over, a condition `{action: "answered", answer: "yes"
229
+ * | "no"}` once it is decided.
228
230
  * - `$inapp` `{message, journey, action: "shown" | "clicked" | "dismissed"}`; the journey moves the person on.
229
231
  */
230
232
 
@@ -276,13 +278,37 @@ interface OnboardingStep {
276
278
  /** Checklist items: the event that ticks them (`track("<event>")`). Without it the person ticks it themselves. */
277
279
  event?: string;
278
280
  }
281
+ /** A pause in a flow (0.11.0): the next step shows this long after the one before was done. */
282
+ interface OnboardingWait {
283
+ id: string;
284
+ kind: "wait";
285
+ amount: number;
286
+ unit: "hours" | "days";
287
+ }
288
+ /**
289
+ * A fork in a flow (0.11.0): "Did <event> happen?" (the person's events, here and on MRRocket) or "Is on <page>?" (the
290
+ * page they are on when they reach it; `*` at the end for everything below). Decided once, when the person reaches it;
291
+ * like a journey's branch it ends its line, the rest of the flow lives in its two paths.
292
+ */
293
+ interface OnboardingCondition {
294
+ id: string;
295
+ kind: "condition";
296
+ event?: string;
297
+ page?: string;
298
+ yes: OnboardingNode[];
299
+ no: OnboardingNode[];
300
+ }
301
+ type OnboardingNode = OnboardingStep | OnboardingWait | OnboardingCondition;
279
302
  /** A live onboarding flow's in-app steps (MRRocket › Conversion › Onboarding); its emails go out from MRRocket. */
280
303
  interface OnboardingFlow {
281
304
  id: string;
282
305
  name: string;
283
306
  /** When it starts: an event's words ("Signed up", "First export"), or "First launch" / "First visit". */
284
307
  trigger: string;
308
+ /** What every version shows: with `nodes`, only the in-app steps before the first wait or condition. */
285
309
  steps: OnboardingStep[];
310
+ /** 0.11.0+: the whole flow with its waits and conditions. Read instead of `steps` when it is there. */
311
+ nodes?: OnboardingNode[];
286
312
  }
287
313
  /** An in-app message a Lifecycle journey has for this person. */
288
314
  interface InAppMessage {
@@ -395,7 +421,7 @@ interface FlowsController {
395
421
 
396
422
  type VariantId = string;
397
423
  /** This build of the SDK. Sent with the config request so MRRocket knows what the site runs (error tracking, flags). */
398
- declare const SDK_VERSION = "0.10.0";
424
+ declare const SDK_VERSION = "0.11.0";
399
425
  /** One variant of an experiment. `changes` replaces texts that the page marks with `<Text id>`. */
400
426
  interface VariantConfig {
401
427
  id: VariantId;
@@ -224,7 +224,9 @@ type Api = ReturnType<typeof createApi>;
224
224
  * `finished` with `outcome: "saved"` into a save and with `outcome: "cancelled"` into a cancellation on the Churn page.
225
225
  * MRRocket never changes a subscription: an accepted offer goes to the app's `onSave`, a confirmed cancel to its
226
226
  * `onCancel`, and only after they succeed is the outcome recorded.
227
- * - `$onboarding` `{flow, step?, action: "started" | "shown" | "completed" | "dismissed"}`.
227
+ * - `$onboarding` `{flow, step?, action: "started" | "shown" | "completed" | "dismissed"}`; since 0.11.0 a wait records
228
+ * "shown" when the person reaches it and "completed" when it is over, a condition `{action: "answered", answer: "yes"
229
+ * | "no"}` once it is decided.
228
230
  * - `$inapp` `{message, journey, action: "shown" | "clicked" | "dismissed"}`; the journey moves the person on.
229
231
  */
230
232
 
@@ -276,13 +278,37 @@ interface OnboardingStep {
276
278
  /** Checklist items: the event that ticks them (`track("<event>")`). Without it the person ticks it themselves. */
277
279
  event?: string;
278
280
  }
281
+ /** A pause in a flow (0.11.0): the next step shows this long after the one before was done. */
282
+ interface OnboardingWait {
283
+ id: string;
284
+ kind: "wait";
285
+ amount: number;
286
+ unit: "hours" | "days";
287
+ }
288
+ /**
289
+ * A fork in a flow (0.11.0): "Did <event> happen?" (the person's events, here and on MRRocket) or "Is on <page>?" (the
290
+ * page they are on when they reach it; `*` at the end for everything below). Decided once, when the person reaches it;
291
+ * like a journey's branch it ends its line, the rest of the flow lives in its two paths.
292
+ */
293
+ interface OnboardingCondition {
294
+ id: string;
295
+ kind: "condition";
296
+ event?: string;
297
+ page?: string;
298
+ yes: OnboardingNode[];
299
+ no: OnboardingNode[];
300
+ }
301
+ type OnboardingNode = OnboardingStep | OnboardingWait | OnboardingCondition;
279
302
  /** A live onboarding flow's in-app steps (MRRocket › Conversion › Onboarding); its emails go out from MRRocket. */
280
303
  interface OnboardingFlow {
281
304
  id: string;
282
305
  name: string;
283
306
  /** When it starts: an event's words ("Signed up", "First export"), or "First launch" / "First visit". */
284
307
  trigger: string;
308
+ /** What every version shows: with `nodes`, only the in-app steps before the first wait or condition. */
285
309
  steps: OnboardingStep[];
310
+ /** 0.11.0+: the whole flow with its waits and conditions. Read instead of `steps` when it is there. */
311
+ nodes?: OnboardingNode[];
286
312
  }
287
313
  /** An in-app message a Lifecycle journey has for this person. */
288
314
  interface InAppMessage {
@@ -395,7 +421,7 @@ interface FlowsController {
395
421
 
396
422
  type VariantId = string;
397
423
  /** This build of the SDK. Sent with the config request so MRRocket knows what the site runs (error tracking, flags). */
398
- declare const SDK_VERSION = "0.10.0";
424
+ declare const SDK_VERSION = "0.11.0";
399
425
  /** One variant of an experiment. `changes` replaces texts that the page marks with `<Text id>`. */
400
426
  interface VariantConfig {
401
427
  id: VariantId;
@@ -49,7 +49,7 @@ module.exports = __toCommonJS(native_exports);
49
49
  var import_react3 = require("react");
50
50
 
51
51
  // src/core.ts
52
- var SDK_VERSION = "0.10.0";
52
+ var SDK_VERSION = "0.11.0";
53
53
  var propOf = (person, name) => {
54
54
  const want = name.trim().toLowerCase();
55
55
  for (const [k, v] of Object.entries(person)) if (k.toLowerCase() === want) return v;
@@ -329,6 +329,19 @@ var safeUrl = (url) => {
329
329
  const u = (url ?? "").trim();
330
330
  return /^https?:\/\//i.test(u) || /^\/(?!\/)/.test(u) ? u : void 0;
331
331
  };
332
+ var nodesOf = (f) => f.nodes ?? f.steps;
333
+ var waitMs = (w) => Math.max(0, w.amount) * (w.unit === "hours" ? 36e5 : 864e5);
334
+ var pageMatches = (pattern, path) => {
335
+ const p = pattern.trim();
336
+ const at = (path.split(/[?#]/)[0] || "/").replace(/\/+$/, "") || "/";
337
+ if (!p) return false;
338
+ if (p.endsWith("*")) {
339
+ const base = p.slice(0, -1).replace(/\/+$/, "");
340
+ return !base || at === base || at.startsWith(`${base}/`);
341
+ }
342
+ return at === (p.replace(/\/+$/, "") || "/");
343
+ };
344
+ var stepsIn = (nodes) => nodes.flatMap((n) => n.kind === "condition" ? [...stepsIn(n.yes), ...stepsIn(n.no)] : n.kind === "wait" ? [] : [n]);
332
345
  var createFlows = (env) => {
333
346
  let config = null;
334
347
  let cancel = null;
@@ -343,25 +356,99 @@ var createFlows = (env) => {
343
356
  const shown = /* @__PURE__ */ new Set();
344
357
  const listeners = /* @__PURE__ */ new Set();
345
358
  const auto = env.auto !== false;
359
+ const seenEvents = /* @__PURE__ */ new Set();
360
+ const now = () => env.now?.() ?? Date.now();
361
+ let timer = null;
346
362
  const flows = () => config?.onboarding ?? [];
347
363
  const flowById = (id) => id ? flows().find((f) => f.id === id) : void 0;
348
364
  const prog = (id) => {
349
365
  let p = progress.get(id);
350
- if (!p) progress.set(id, p = { done: /* @__PURE__ */ new Set(), dismissed: false });
366
+ if (!p) progress.set(id, p = { done: /* @__PURE__ */ new Set(), dismissed: false, answers: /* @__PURE__ */ new Map(), at: /* @__PURE__ */ new Map(), reached: /* @__PURE__ */ new Map() });
351
367
  return p;
352
368
  };
369
+ let dirty = false;
370
+ const record = (props) => {
371
+ env.track(ONBOARDING_EVENT, props);
372
+ dirty = true;
373
+ };
374
+ const markDone = (p, id) => {
375
+ p.done.add(id);
376
+ p.at.set(id, now());
377
+ };
378
+ const decide = (c) => {
379
+ if (c.page?.trim()) {
380
+ const path = env.path?.();
381
+ return path === void 0 ? false : pageMatches(c.page, path);
382
+ }
383
+ if (c.event?.trim()) {
384
+ if (!known) return void 0;
385
+ for (const e of seenEvents) if (matchesTrigger(c.event, e)) return true;
386
+ return false;
387
+ }
388
+ return false;
389
+ };
390
+ const walkLine = (flow, line, p, live, before = 0, last) => {
391
+ for (let i = 0; i < line.length; i++) {
392
+ const n = line[i];
393
+ if (n.kind === "wait") {
394
+ if (p.done.has(n.id)) {
395
+ last = p.at.get(n.id) ?? last;
396
+ continue;
397
+ }
398
+ let since = p.reached.get(n.id);
399
+ if (since === void 0) {
400
+ if (!live) return { at: "pending" };
401
+ since = last ?? now();
402
+ p.reached.set(n.id, since);
403
+ record({ flow: flow.id, step: n.id, kind: "wait", action: "shown" });
404
+ }
405
+ const until = since + waitMs(n);
406
+ if (now() < until) return { at: "wait", until };
407
+ if (!live) return { at: "pending" };
408
+ markDone(p, n.id);
409
+ record({ flow: flow.id, step: n.id, kind: "wait", action: "completed" });
410
+ last = until;
411
+ continue;
412
+ }
413
+ if (n.kind === "condition") {
414
+ let answer = p.answers.get(n.id);
415
+ if (answer === void 0) {
416
+ if (!live) return { at: "pending" };
417
+ answer = decide(n);
418
+ if (answer === void 0) return { at: "pending" };
419
+ p.answers.set(n.id, answer);
420
+ record({ flow: flow.id, step: n.id, kind: "condition", action: "answered", answer: answer ? "yes" : "no" });
421
+ }
422
+ return walkLine(flow, answer ? n.yes : n.no, p, live, before, last);
423
+ }
424
+ if (!p.done.has(n.id)) return { at: "step", step: n, line, index: i, before };
425
+ last = p.at.get(n.id) ?? last;
426
+ before++;
427
+ }
428
+ return { at: "end" };
429
+ };
353
430
  const finished = (f) => {
354
431
  const p = progress.get(f.id);
355
- return !!p && (p.dismissed || f.steps.every((s) => p.done.has(s.id)));
432
+ return !!p && (p.dismissed || walkLine(f, nodesOf(f), p, false).at === "end");
356
433
  };
357
434
  const keyOf = (pid) => `${PROGRESS_KEY}:${pid ?? "visitor"}`;
435
+ const eventsKeyOf = (pid) => `${PROGRESS_KEY}:events:${pid ?? "visitor"}`;
358
436
  const save = () => {
437
+ dirty = false;
359
438
  const store = env.storage?.();
360
439
  if (!store) return;
361
440
  const out = {};
362
- for (const [id, p] of progress) out[id] = { done: [...p.done], ...p.dismissed ? { dismissed: true } : {} };
441
+ for (const [id, p] of progress)
442
+ out[id] = {
443
+ done: [...p.done],
444
+ ...p.dismissed ? { dismissed: true } : {},
445
+ ...p.answers.size ? { answers: Object.fromEntries([...p.answers].map(([k, v]) => [k, v ? "yes" : "no"])) } : {},
446
+ ...p.at.size ? { at: Object.fromEntries(p.at) } : {},
447
+ ...p.reached.size ? { reached: Object.fromEntries(p.reached) } : {}
448
+ };
363
449
  try {
364
450
  store.set(keyOf(env.personId()), JSON.stringify(out));
451
+ if (seenEvents.size) store.set(eventsKeyOf(env.personId()), JSON.stringify([...seenEvents].slice(-200)));
365
452
  } catch {
366
453
  }
367
454
  };
@@ -370,23 +457,55 @@ var createFlows = (env) => {
370
457
  const p = prog(id);
371
458
  for (const d of s.done ?? []) p.done.add(d);
372
459
  if (s.dismissed) p.dismissed = true;
460
+ for (const [k, v] of Object.entries(s.answers ?? {})) if (!p.answers.has(k)) p.answers.set(k, v === "yes");
461
+ for (const [k, v] of Object.entries(s.at ?? {})) if (typeof v === "number" && !p.at.has(k)) p.at.set(k, v);
462
+ for (const [k, v] of Object.entries(s.reached ?? {})) if (typeof v === "number" && (!p.reached.has(k) || v < p.reached.get(k))) p.reached.set(k, v);
463
+ }
464
+ };
465
+ const wakeAt = (until) => {
466
+ if (timer) clearTimeout(timer);
467
+ timer = null;
468
+ const ms = until - now();
469
+ if (ms > 0 && ms <= 864e5) {
470
+ timer = setTimeout(() => (timer = null, emit()), ms + 50);
471
+ timer.unref?.();
373
472
  }
374
473
  };
474
+ let holding = false;
375
475
  const onboardingView = () => {
476
+ holding = false;
376
477
  const flow = flowById(active);
377
478
  if (!flow) return null;
378
479
  const p = prog(flow.id);
379
- const at = flow.steps.findIndex((s) => !p.done.has(s.id));
380
- if (at < 0 || p.dismissed) return null;
381
- const step = flow.steps[at];
480
+ if (p.dismissed) return null;
481
+ const w = walkLine(flow, nodesOf(flow), p, true);
482
+ if (dirty) save();
483
+ if (w.at === "wait") wakeAt(w.until);
484
+ holding = w.at === "wait" || w.at === "pending";
485
+ if (w.at !== "step") return null;
486
+ const { step, line, index } = w;
487
+ const isStep = (n) => !!n && n.kind !== "wait" && n.kind !== "condition";
382
488
  if (step.kind === "checklist") {
383
- let from = at;
384
- while (from > 0 && flow.steps[from - 1].kind === "checklist") from--;
385
- let to = at;
386
- while (to + 1 < flow.steps.length && flow.steps[to + 1].kind === "checklist") to++;
387
- return { type: "checklist", flow, items: flow.steps.slice(from, to + 1).map((s) => ({ step: s, done: p.done.has(s.id) })) };
489
+ let from = index;
490
+ while (from > 0 && isStep(line[from - 1]) && line[from - 1].kind === "checklist") from--;
491
+ let to = index;
492
+ while (to + 1 < line.length && isStep(line[to + 1]) && line[to + 1].kind === "checklist") to++;
493
+ const items = line.slice(from, to + 1).filter(isStep);
494
+ for (const i of items)
495
+ if (!p.done.has(i.id) && !shown.has(`s:${i.id}`)) {
496
+ shown.add(`s:${i.id}`);
497
+ env.track(ONBOARDING_EVENT, { flow: flow.id, step: i.id, kind: "checklist", action: "shown" });
498
+ }
499
+ return { type: "checklist", flow, items: items.map((s) => ({ step: s, done: p.done.has(s.id) })) };
500
+ }
501
+ if (!shown.has(`s:${step.id}`)) {
502
+ shown.add(`s:${step.id}`);
503
+ env.track(ONBOARDING_EVENT, { flow: flow.id, step: step.id, kind: step.kind, action: "shown" });
388
504
  }
389
- return { type: "step", flow, step, index: at, total: flow.steps.length, last: at === flow.steps.length - 1 };
505
+ const rest = line.slice(index + 1);
506
+ const fork = rest.findIndex((n) => n.kind === "condition");
507
+ const ahead = (fork < 0 ? rest : rest.slice(0, fork)).filter(isStep).length;
508
+ return { type: "step", flow, step, index: w.before, total: w.before + 1 + ahead, last: !ahead && fork < 0 };
390
509
  };
391
510
  const view = () => {
392
511
  if (cancel) {
@@ -406,22 +525,13 @@ var createFlows = (env) => {
406
525
  }
407
526
  const ob = onboardingView();
408
527
  if (ob) return ob;
409
- if (active) active = null;
528
+ if (active && !holding) active = null;
410
529
  const m = messages.find((x) => !handled.has(x.id));
411
530
  return m ? { type: "message", message: m } : null;
412
531
  };
413
532
  const emit = () => {
414
533
  const v = view();
415
- if (v?.type === "step" && !shown.has(`s:${v.step.id}`)) {
416
- shown.add(`s:${v.step.id}`);
417
- env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: v.step.id, kind: v.step.kind, action: "shown" });
418
- } else if (v?.type === "checklist") {
419
- for (const i of v.items)
420
- if (!i.done && !shown.has(`s:${i.step.id}`)) {
421
- shown.add(`s:${i.step.id}`);
422
- env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: i.step.id, kind: "checklist", action: "shown" });
423
- }
424
- } else if (v?.type === "message" && !shown.has(`m:${v.message.id}`)) {
534
+ if (v?.type === "message" && !shown.has(`m:${v.message.id}`)) {
425
535
  shown.add(`m:${v.message.id}`);
426
536
  env.track(INAPP_EVENT, { message: v.message.id, journey: v.message.journey, action: "shown" });
427
537
  }
@@ -443,13 +553,14 @@ var createFlows = (env) => {
443
553
  const autoStart = () => {
444
554
  if (!auto || active || !known) return;
445
555
  if (!env.personId() && !env.storage?.()) return;
446
- const next = flows().find((f) => startsOnLoad(f.trigger) && f.steps.length && !finished(f));
556
+ const next = flows().find((f) => startsOnLoad(f.trigger) && nodesOf(f).length && !finished(f));
447
557
  if (next) start(next);
448
558
  };
449
559
  const refresh = () => {
450
560
  const pid = env.personId();
451
561
  if (progressFor !== null && progressFor !== pid) {
452
562
  progress.clear();
563
+ seenEvents.clear();
453
564
  handled.clear();
454
565
  active = null;
455
566
  loadedFor = void 0;
@@ -461,6 +572,10 @@ var createFlows = (env) => {
461
572
  try {
462
573
  const raw = await store.get(keyOf(pid));
463
574
  if (raw) merge(JSON.parse(raw));
575
+ const seen = await store.get(eventsKeyOf(pid));
576
+ if (seen) {
577
+ for (const e of JSON.parse(seen)) if (typeof e === "string") seenEvents.add(e);
578
+ }
464
579
  } catch {
465
580
  }
466
581
  }
@@ -470,6 +585,7 @@ var createFlows = (env) => {
470
585
  const state = await env.fetchInApp(pid).catch(() => null);
471
586
  if (state && env.personId() === pid) {
472
587
  merge(state.onboarding);
588
+ for (const e of state.events ?? []) if (typeof e === "string") seenEvents.add(eventSlug(e));
473
589
  messages = (state.messages ?? []).filter((m) => m && m.id && m.title);
474
590
  }
475
591
  }
@@ -546,29 +662,32 @@ var createFlows = (env) => {
546
662
  refresh,
547
663
  onEvent: (name) => {
548
664
  if (!name || name.startsWith("$")) return;
665
+ const slug = eventSlug(name);
666
+ const fresh = !!slug && !seenEvents.has(slug);
667
+ if (fresh) seenEvents.add(slug);
549
668
  const flow = flowById(active);
550
669
  let ticked = false;
551
670
  if (flow) {
552
671
  const p = prog(flow.id);
553
- for (const s of flow.steps)
672
+ for (const s of stepsIn(nodesOf(flow)))
554
673
  if (s.kind === "checklist" && s.event && !p.done.has(s.id) && matchesTrigger(s.event, name)) {
555
- p.done.add(s.id);
674
+ markDone(p, s.id);
556
675
  ticked = true;
557
676
  env.track(ONBOARDING_EVENT, { flow: flow.id, step: s.id, kind: s.kind, action: "completed" });
558
677
  }
559
678
  }
560
- if (ticked) {
679
+ if (ticked || fresh && flow) {
561
680
  save();
562
681
  emit();
563
- }
682
+ } else if (fresh) save();
564
683
  if (!auto || !env.personId() && !env.storage?.()) return;
565
- const next = flows().find((f) => f.steps.length && matchesTrigger(f.trigger, name) && !finished(f));
684
+ const next = flows().find((f) => nodesOf(f).length && matchesTrigger(f.trigger, name) && !finished(f));
566
685
  if (next) start(next);
567
686
  },
568
687
  openCancelFlow,
569
688
  startOnboarding: (id) => {
570
- const f = id ? flowById(id) : flows().find((x) => x.steps.length && !finished(x));
571
- if (!f || !f.steps.length) return false;
689
+ const f = id ? flowById(id) : flows().find((x) => nodesOf(x).length && !finished(x));
690
+ if (!f || !nodesOf(f).length) return false;
572
691
  const p = prog(f.id);
573
692
  p.dismissed = false;
574
693
  return start(f);
@@ -639,7 +758,7 @@ var createFlows = (env) => {
639
758
  next: () => {
640
759
  const v = onboardingView();
641
760
  if (v?.type !== "step") return;
642
- prog(v.flow.id).done.add(v.step.id);
761
+ markDone(prog(v.flow.id), v.step.id);
643
762
  env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: v.step.id, kind: v.step.kind, action: "completed" });
644
763
  save();
645
764
  emit();
@@ -649,7 +768,7 @@ var createFlows = (env) => {
649
768
  if (v?.type !== "checklist") return;
650
769
  const item = v.items.find((i) => i.step.id === stepId);
651
770
  if (!item || item.done) return;
652
- prog(v.flow.id).done.add(stepId);
771
+ markDone(prog(v.flow.id), stepId);
653
772
  env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: stepId, kind: "checklist", action: "completed" });
654
773
  save();
655
774
  emit();