@mrrocket/sdk 0.4.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +137 -8
  3. package/dist/api-client.d.ts +213 -0
  4. package/dist/api-client.js +67 -0
  5. package/dist/api-client.js.map +1 -0
  6. package/dist/api.d.ts +9 -109
  7. package/dist/api.js +7 -62
  8. package/dist/api.js.map +1 -1
  9. package/dist/browser/mrrocket-flows.js +64 -0
  10. package/dist/browser/mrrocket-messenger.js +313 -0
  11. package/dist/browser/mrrocket-push.js +1 -0
  12. package/dist/browser/mrrocket-replay.js +3 -3
  13. package/dist/browser/mrrocket-sw.js +1 -0
  14. package/dist/browser/mrrocket.js +2 -1
  15. package/dist/browser.d.ts +3 -2
  16. package/dist/browser.js +6 -1
  17. package/dist/browser.js.map +1 -1
  18. package/dist/capture.d.ts +1 -13
  19. package/dist/capture.js +1 -22
  20. package/dist/capture.js.map +1 -1
  21. package/dist/client.d.ts +36 -1
  22. package/dist/client.js +161 -6
  23. package/dist/client.js.map +1 -1
  24. package/dist/context.d.ts +10 -0
  25. package/dist/context.js.map +1 -1
  26. package/dist/core.d.ts +64 -3
  27. package/dist/core.js +54 -2
  28. package/dist/core.js.map +1 -1
  29. package/dist/feedback.d.ts +19 -4
  30. package/dist/feedback.js +655 -190
  31. package/dist/feedback.js.map +1 -1
  32. package/dist/flows-ui.d.ts +23 -0
  33. package/dist/flows-ui.js +384 -0
  34. package/dist/flows-ui.js.map +1 -0
  35. package/dist/flows-web.d.ts +19 -0
  36. package/dist/flows-web.js +42 -0
  37. package/dist/flows-web.js.map +1 -0
  38. package/dist/flows.d.ts +222 -0
  39. package/dist/flows.js +409 -0
  40. package/dist/flows.js.map +1 -0
  41. package/dist/index.d.ts +5 -2
  42. package/dist/index.js +13 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/messenger-ui.d.ts +47 -0
  45. package/dist/messenger-ui.js +583 -0
  46. package/dist/messenger-ui.js.map +1 -0
  47. package/dist/messenger.d.ts +37 -0
  48. package/dist/messenger.js +69 -0
  49. package/dist/messenger.js.map +1 -0
  50. package/dist/proxy.d.ts +34 -1
  51. package/dist/proxy.js +14 -1
  52. package/dist/proxy.js.map +1 -1
  53. package/dist/push-web.d.ts +43 -0
  54. package/dist/push-web.js +81 -0
  55. package/dist/push-web.js.map +1 -0
  56. package/dist/rage.d.ts +14 -0
  57. package/dist/rage.js +27 -0
  58. package/dist/rage.js.map +1 -0
  59. package/dist/react-native/index.d.cts +640 -3
  60. package/dist/react-native/index.d.ts +640 -3
  61. package/dist/react-native/index.js +2015 -34
  62. package/dist/react-native/index.js.map +1 -1
  63. package/dist/styles-css.d.ts +6 -0
  64. package/dist/styles-css.js +327 -0
  65. package/dist/styles-css.js.map +1 -0
  66. package/dist/styles.d.ts +1 -2
  67. package/dist/styles.js +3 -134
  68. package/dist/styles.js.map +1 -1
  69. package/dist/ui.d.ts +3 -0
  70. package/dist/ui.js +12 -0
  71. package/dist/ui.js.map +1 -1
  72. package/package.json +20 -4
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The cancel flow, onboarding and in-app messages (SDK 0.7.0): what MRRocket's config and `GET /api/sdk/inapp` serve,
3
+ * and one controller that decides what shows when and records what the person did. No DOM and no React: the web
4
+ * renderer (flows-ui.ts, loaded only when something shows) and the React Native one (native/flows-view.ts) draw
5
+ * `view()` and call the actions.
6
+ *
7
+ * What it records, as normal events under the SDK's analytics consent (nothing with consent "off"):
8
+ * - `$cancel_flow` `{action: "shown" | "answered" | "offer" | "finished", run, version, …}`. MRRocket turns a
9
+ * `finished` with `outcome: "saved"` into a save and with `outcome: "cancelled"` into a cancellation on the Churn page.
10
+ * MRRocket never changes a subscription: an accepted offer goes to the app's `onSave`, a confirmed cancel to its
11
+ * `onCancel`, and only after they succeed is the outcome recorded.
12
+ * - `$onboarding` `{flow, step?, action: "started" | "shown" | "completed" | "dismissed"}`.
13
+ * - `$inapp` `{message, journey, action: "shown" | "clicked" | "dismissed"}`; the journey moves the person on.
14
+ */
15
+ import type { CockpitConfig } from "./core.js";
16
+ export declare const CANCEL_EVENT = "$cancel_flow";
17
+ export declare const ONBOARDING_EVENT = "$onboarding";
18
+ export declare const INAPP_EVENT = "$inapp";
19
+ export type CancelOfferKind = "discount" | "pause" | "downgrade" | "contact";
20
+ /** What one answer of the survey offers before the confirmation. An answer without one goes straight to it. */
21
+ export interface CancelOffer {
22
+ kind: CancelOfferKind;
23
+ headline: string;
24
+ /** Discount: percent off. */
25
+ percent?: number;
26
+ /** Discount: for how many months; pause: up to how many months. */
27
+ months?: number;
28
+ /** Downgrade: the plan to switch to, as MRRocket knows it from the payment provider. */
29
+ plan?: string;
30
+ planName?: string;
31
+ }
32
+ /** The product's live cancel flow (MRRocket › Churn › Cancel flow). */
33
+ export interface CancelFlowConfig {
34
+ /** Grows with every publish. */
35
+ version: number;
36
+ question: string;
37
+ /** The survey's answers, in order. */
38
+ reasons: {
39
+ id: string;
40
+ label: string;
41
+ }[];
42
+ /** The offer per answer id. */
43
+ offers: Record<string, CancelOffer>;
44
+ confirm: {
45
+ title: string;
46
+ body: string;
47
+ button: string;
48
+ };
49
+ /**
50
+ * Who gets the survey and the offers: business customers only. Anyone else (a consumer, or a customer the app does
51
+ * not say) gets the plain confirmation, with nothing in the way of cancelling (§ 312k BGB).
52
+ */
53
+ offersFor: "business";
54
+ }
55
+ export type OnboardingStepKind = "tour" | "checklist" | "modal";
56
+ export interface OnboardingStep {
57
+ id: string;
58
+ kind: OnboardingStepKind;
59
+ title: string;
60
+ body?: string;
61
+ button?: string;
62
+ /** Tour steps: the element they point at, marked `data-ck-target="<target>"` in the app. Without it: a card. */
63
+ target?: string;
64
+ /** Checklist items: the event that ticks them (`track("<event>")`). Without it the person ticks it themselves. */
65
+ event?: string;
66
+ }
67
+ /** A live onboarding flow's in-app steps (MRRocket › Conversion › Onboarding); its emails go out from MRRocket. */
68
+ export interface OnboardingFlow {
69
+ id: string;
70
+ name: string;
71
+ /** When it starts: an event's words ("Signed up", "First export"), or "First launch" / "First visit". */
72
+ trigger: string;
73
+ steps: OnboardingStep[];
74
+ }
75
+ /** An in-app message a Lifecycle journey has for this person. */
76
+ export interface InAppMessage {
77
+ id: string;
78
+ journey?: string;
79
+ title: string;
80
+ body?: string;
81
+ button?: string;
82
+ /** Where the button goes (http(s) or a path of the app). */
83
+ url?: string;
84
+ style: "banner" | "modal";
85
+ }
86
+ /** What `GET /api/sdk/inapp` answers for one signed-in person. */
87
+ export interface InAppState {
88
+ /** Progress per onboarding flow: the steps done and whether the person closed it. */
89
+ onboarding: Record<string, {
90
+ done: string[];
91
+ dismissed?: boolean;
92
+ }>;
93
+ messages: InAppMessage[];
94
+ }
95
+ export interface CancelFlowResult {
96
+ /** "cancelled": confirmed; "saved": took the offer; "kept": closed the dialog without cancelling. */
97
+ outcome: "cancelled" | "saved" | "kept";
98
+ reason?: string;
99
+ reasonLabel?: string;
100
+ offer?: CancelOffer;
101
+ /** What the customer wrote on the confirmation, if anything. */
102
+ comment?: string;
103
+ /** This run's id, the same as in the events. */
104
+ run: string;
105
+ }
106
+ export interface CancelFlowOptions {
107
+ /**
108
+ * "business" shows the survey and the offers; anything else (the default) only the plain confirmation, so a
109
+ * consumer can always cancel in one step (§ 312k BGB).
110
+ */
111
+ customer?: "business" | "consumer";
112
+ /** The customer's current price, to show what a discount costs (`{ amount: 29, currency: "EUR" }`). */
113
+ price?: {
114
+ amount: number;
115
+ currency: string;
116
+ interval?: "month" | "year";
117
+ };
118
+ /** The customer confirmed: cancel the subscription in your code. Throwing keeps the dialog open with an error. */
119
+ onCancel?: (result: CancelFlowResult) => unknown;
120
+ /** The customer took an offer: apply it in your code (coupon, pause, plan change). Throwing keeps the dialog open. */
121
+ onSave?: (result: CancelFlowResult) => unknown;
122
+ /** Closed without cancelling or taking an offer. */
123
+ onClose?: (result: CancelFlowResult) => void;
124
+ }
125
+ /** What a renderer draws. */
126
+ export type FlowView = {
127
+ type: "cancel";
128
+ screen: "survey" | "offer" | "confirm";
129
+ flow: CancelFlowConfig;
130
+ /** The answer picked on the survey. */
131
+ reason?: string;
132
+ offer?: CancelOffer;
133
+ price?: CancelFlowOptions["price"];
134
+ busy: boolean;
135
+ error?: string;
136
+ /** The step for the dots, 1 to `steps`. */
137
+ step: number;
138
+ steps: number;
139
+ } | {
140
+ type: "step";
141
+ flow: OnboardingFlow;
142
+ step: OnboardingStep;
143
+ index: number;
144
+ total: number;
145
+ last: boolean;
146
+ } | {
147
+ type: "checklist";
148
+ flow: OnboardingFlow;
149
+ items: {
150
+ step: OnboardingStep;
151
+ done: boolean;
152
+ }[];
153
+ } | {
154
+ type: "message";
155
+ message: InAppMessage;
156
+ };
157
+ export interface FlowsEnv {
158
+ /** Records an event under the SDK's consent (the normal queue). */
159
+ track(event: string, props?: Record<string, unknown>): void;
160
+ /** Whose messages and progress these are: the identify() id; undefined while nobody is signed in. */
161
+ personId(): string | undefined;
162
+ /** `GET /api/sdk/inapp` for that person; null when it fails. */
163
+ fetchInApp(personId: string): Promise<InAppState | null>;
164
+ /** Keeps onboarding progress on the device; only while the consent lets the SDK store something. */
165
+ storage?: () => {
166
+ get(key: string): string | null | Promise<string | null>;
167
+ set(key: string, value: string): void;
168
+ } | null;
169
+ /** Opens a message's link. */
170
+ openUrl?: (url: string) => void;
171
+ /** Start onboarding flows by their trigger on its own (default true). */
172
+ auto?: boolean;
173
+ }
174
+ export interface FlowsController {
175
+ setConfig(config: CockpitConfig | null): void;
176
+ /** The person changed (identify, reset, consent): loads their messages and onboarding progress. */
177
+ refresh(): Promise<void>;
178
+ /** An event the app recorded: starts flows whose trigger it is and ticks checklist items. */
179
+ onEvent(name: string): void;
180
+ openCancelFlow(options?: CancelFlowOptions): Promise<CancelFlowResult>;
181
+ /** Starts an onboarding flow now (the first live one without an id). False when there is none to start. */
182
+ startOnboarding(id?: string): boolean;
183
+ view(): FlowView | null;
184
+ subscribe(listener: () => void): () => void;
185
+ /** `accept(comment)`: a "Talk to us" offer sends what the customer wrote with it. */
186
+ cancel: {
187
+ pick(reason: string): void;
188
+ next(): void;
189
+ accept(comment?: string): void;
190
+ decline(): void;
191
+ confirm(comment?: string): void;
192
+ close(): void;
193
+ };
194
+ onboarding: {
195
+ next(): void;
196
+ check(stepId: string): void;
197
+ dismiss(): void;
198
+ };
199
+ message: {
200
+ click(): void;
201
+ dismiss(): void;
202
+ };
203
+ }
204
+ /** The plain confirmation, when the product has no live cancel flow (or MRRocket was not reachable). */
205
+ export declare const PLAIN_CANCEL: CancelFlowConfig;
206
+ /** `"Signed up"` → `signed_up`, the way MRRocket names events from words. */
207
+ export declare const eventSlug: (s: string) => string;
208
+ /** Whether recording `event` is the trigger's moment ("Signed up" is any of the usual signup event names). */
209
+ export declare const matchesTrigger: (trigger: string, event: string) => boolean;
210
+ /** Triggers that mean "the first time the person opens the app": the flow starts on load until it is done. */
211
+ export declare const startsOnLoad: (trigger: string) => boolean;
212
+ /** A random id for one cancel flow run. */
213
+ export declare const runId: () => string;
214
+ /** The money a discount leaves: `29 EUR` at 30 % → `20.30`. */
215
+ export declare const discounted: (amount: number, percent: number) => number;
216
+ /** "€20.30" in the customer's own currency, without a library. */
217
+ export declare const money: (amount: number, currency: string) => string;
218
+ /** The button of an offer. */
219
+ export declare const offerButton: (o: CancelOffer) => "Send to the team" | "Pause instead" | "Switch plan" | "Accept offer";
220
+ /** A link a message may open: http(s), or a path of the app. Nothing else (no `javascript:`). */
221
+ export declare const safeUrl: (url: string | undefined) => string | undefined;
222
+ export declare const createFlows: (env: FlowsEnv) => FlowsController;
package/dist/flows.js ADDED
@@ -0,0 +1,409 @@
1
+ // src/flows.ts
2
+ var CANCEL_EVENT = "$cancel_flow";
3
+ var ONBOARDING_EVENT = "$onboarding";
4
+ var INAPP_EVENT = "$inapp";
5
+ var PLAIN_CANCEL = {
6
+ version: 0,
7
+ question: "",
8
+ reasons: [],
9
+ offers: {},
10
+ confirm: { title: "Cancel your subscription?", body: "You keep access until the end of the billing period.", button: "Cancel subscription" },
11
+ offersFor: "business"
12
+ };
13
+ var FAILED = "Something went wrong. Please try again.";
14
+ var PROGRESS_KEY = "mrrocket:onboarding";
15
+ var eventSlug = (s) => s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
16
+ var SIGNUP = /* @__PURE__ */ new Set(["signed_up", "signup", "sign_up", "registered", "account_created", "user_signed_up"]);
17
+ var matchesTrigger = (trigger, event) => {
18
+ const t = eventSlug(trigger);
19
+ const e = eventSlug(event);
20
+ if (!t || !e) return false;
21
+ return t === e || SIGNUP.has(t) && SIGNUP.has(e);
22
+ };
23
+ var startsOnLoad = (trigger) => /^(first (launch|visit|open|session|login|sign ?in|app open)|app (opened|launched)|on load|every visit|always)?$/i.test(trigger.trim());
24
+ var runId = () => `cf_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
25
+ var discounted = (amount, percent) => Math.round(amount * (1 - percent / 100) * 100) / 100;
26
+ var money = (amount, currency) => {
27
+ try {
28
+ return new Intl.NumberFormat(void 0, { style: "currency", currency }).format(amount);
29
+ } catch {
30
+ return `${amount.toFixed(2)} ${currency}`;
31
+ }
32
+ };
33
+ var offerButton = (o) => o.kind === "contact" ? "Send to the team" : o.kind === "pause" ? "Pause instead" : o.kind === "downgrade" ? "Switch plan" : "Accept offer";
34
+ var safeUrl = (url) => {
35
+ const u = (url ?? "").trim();
36
+ return /^https?:\/\//i.test(u) || /^\/(?!\/)/.test(u) ? u : void 0;
37
+ };
38
+ var createFlows = (env) => {
39
+ let config = null;
40
+ let cancel = null;
41
+ let active = null;
42
+ let messages = [];
43
+ let known = false;
44
+ let loading = null;
45
+ let loadedFor;
46
+ let progressFor = null;
47
+ const progress = /* @__PURE__ */ new Map();
48
+ const handled = /* @__PURE__ */ new Set();
49
+ const shown = /* @__PURE__ */ new Set();
50
+ const listeners = /* @__PURE__ */ new Set();
51
+ const auto = env.auto !== false;
52
+ const flows = () => config?.onboarding ?? [];
53
+ const flowById = (id) => id ? flows().find((f) => f.id === id) : void 0;
54
+ const prog = (id) => {
55
+ let p = progress.get(id);
56
+ if (!p) progress.set(id, p = { done: /* @__PURE__ */ new Set(), dismissed: false });
57
+ return p;
58
+ };
59
+ const finished = (f) => {
60
+ const p = progress.get(f.id);
61
+ return !!p && (p.dismissed || f.steps.every((s) => p.done.has(s.id)));
62
+ };
63
+ const keyOf = (pid) => `${PROGRESS_KEY}:${pid ?? "visitor"}`;
64
+ const save = () => {
65
+ const store = env.storage?.();
66
+ if (!store) return;
67
+ const out = {};
68
+ for (const [id, p] of progress) out[id] = { done: [...p.done], ...p.dismissed ? { dismissed: true } : {} };
69
+ try {
70
+ store.set(keyOf(env.personId()), JSON.stringify(out));
71
+ } catch {
72
+ }
73
+ };
74
+ const merge = (state) => {
75
+ for (const [id, s] of Object.entries(state ?? {})) {
76
+ const p = prog(id);
77
+ for (const d of s.done ?? []) p.done.add(d);
78
+ if (s.dismissed) p.dismissed = true;
79
+ }
80
+ };
81
+ const onboardingView = () => {
82
+ const flow = flowById(active);
83
+ if (!flow) return null;
84
+ const p = prog(flow.id);
85
+ const at = flow.steps.findIndex((s) => !p.done.has(s.id));
86
+ if (at < 0 || p.dismissed) return null;
87
+ const step = flow.steps[at];
88
+ if (step.kind === "checklist") {
89
+ let from = at;
90
+ while (from > 0 && flow.steps[from - 1].kind === "checklist") from--;
91
+ let to = at;
92
+ while (to + 1 < flow.steps.length && flow.steps[to + 1].kind === "checklist") to++;
93
+ return { type: "checklist", flow, items: flow.steps.slice(from, to + 1).map((s) => ({ step: s, done: p.done.has(s.id) })) };
94
+ }
95
+ return { type: "step", flow, step, index: at, total: flow.steps.length, last: at === flow.steps.length - 1 };
96
+ };
97
+ const view = () => {
98
+ if (cancel) {
99
+ const offer = cancel.reason ? cancel.flow.offers[cancel.reason] : void 0;
100
+ return {
101
+ type: "cancel",
102
+ screen: cancel.screen,
103
+ flow: cancel.flow,
104
+ reason: cancel.reason,
105
+ offer: cancel.screen === "offer" ? offer : void 0,
106
+ price: cancel.options.price,
107
+ busy: cancel.busy,
108
+ error: cancel.error,
109
+ step: cancel.plain ? 1 : cancel.screen === "survey" ? 1 : cancel.screen === "offer" ? 2 : 3,
110
+ steps: cancel.plain ? 1 : 3
111
+ };
112
+ }
113
+ const ob = onboardingView();
114
+ if (ob) return ob;
115
+ if (active) active = null;
116
+ const m = messages.find((x) => !handled.has(x.id));
117
+ return m ? { type: "message", message: m } : null;
118
+ };
119
+ const emit = () => {
120
+ const v = view();
121
+ if (v?.type === "step" && !shown.has(`s:${v.step.id}`)) {
122
+ shown.add(`s:${v.step.id}`);
123
+ env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: v.step.id, kind: v.step.kind, action: "shown" });
124
+ } else if (v?.type === "checklist") {
125
+ for (const i of v.items)
126
+ if (!i.done && !shown.has(`s:${i.step.id}`)) {
127
+ shown.add(`s:${i.step.id}`);
128
+ env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: i.step.id, kind: "checklist", action: "shown" });
129
+ }
130
+ } else if (v?.type === "message" && !shown.has(`m:${v.message.id}`)) {
131
+ shown.add(`m:${v.message.id}`);
132
+ env.track(INAPP_EVENT, { message: v.message.id, journey: v.message.journey, action: "shown" });
133
+ }
134
+ for (const l of [...listeners]) {
135
+ try {
136
+ l();
137
+ } catch {
138
+ }
139
+ }
140
+ };
141
+ const start = (flow) => {
142
+ if (active === flow.id) return true;
143
+ if (active && flowById(active) && !finished(flowById(active))) return false;
144
+ active = flow.id;
145
+ env.track(ONBOARDING_EVENT, { flow: flow.id, action: "started" });
146
+ emit();
147
+ return true;
148
+ };
149
+ const autoStart = () => {
150
+ if (!auto || active || !known) return;
151
+ if (!env.personId() && !env.storage?.()) return;
152
+ const next = flows().find((f) => startsOnLoad(f.trigger) && f.steps.length && !finished(f));
153
+ if (next) start(next);
154
+ };
155
+ const refresh = () => {
156
+ const pid = env.personId();
157
+ if (progressFor !== null && progressFor !== pid) {
158
+ progress.clear();
159
+ handled.clear();
160
+ active = null;
161
+ loadedFor = void 0;
162
+ }
163
+ progressFor = pid;
164
+ const run = (async () => {
165
+ const store = env.storage?.();
166
+ if (store) {
167
+ try {
168
+ const raw = await store.get(keyOf(pid));
169
+ if (raw) merge(JSON.parse(raw));
170
+ } catch {
171
+ }
172
+ }
173
+ const wants = flows().length > 0 || !!config?.messages;
174
+ if (pid && wants && loadedFor !== pid) {
175
+ loadedFor = pid;
176
+ const state = await env.fetchInApp(pid).catch(() => null);
177
+ if (state && env.personId() === pid) {
178
+ merge(state.onboarding);
179
+ messages = (state.messages ?? []).filter((m) => m && m.id && m.title);
180
+ }
181
+ }
182
+ if (!pid) messages = [];
183
+ known = true;
184
+ autoStart();
185
+ emit();
186
+ })();
187
+ loading = run.finally(() => {
188
+ if (loading === run) loading = null;
189
+ });
190
+ return loading;
191
+ };
192
+ const result = (c, outcome, comment) => {
193
+ const offer = c.reason ? c.flow.offers[c.reason] : void 0;
194
+ return {
195
+ outcome,
196
+ run: c.run,
197
+ ...c.reason ? { reason: c.reason, reasonLabel: c.flow.reasons.find((r) => r.id === c.reason)?.label } : {},
198
+ ...outcome === "saved" && offer ? { offer } : {},
199
+ ...comment?.trim() ? { comment: comment.trim().slice(0, 500) } : {}
200
+ };
201
+ };
202
+ const finish = (c, r) => {
203
+ env.track(CANCEL_EVENT, {
204
+ action: "finished",
205
+ run: c.run,
206
+ version: c.flow.version,
207
+ outcome: r.outcome,
208
+ customer: c.options.customer ?? "consumer",
209
+ ...r.reason ? { reason: r.reason } : {},
210
+ ...r.offer ? { offer: r.offer.kind } : {},
211
+ ...r.comment ? { comment: r.comment } : {}
212
+ });
213
+ if (cancel === c) cancel = null;
214
+ c.resolve(r);
215
+ emit();
216
+ };
217
+ const settle = (c, outcome, comment) => {
218
+ if (c.busy) return;
219
+ const r = result(c, outcome, comment);
220
+ const fn = outcome === "saved" ? c.options.onSave : c.options.onCancel;
221
+ c.busy = true;
222
+ c.error = void 0;
223
+ emit();
224
+ Promise.resolve().then(() => fn?.(r)).then(
225
+ () => finish(c, r),
226
+ () => {
227
+ c.busy = false;
228
+ c.error = FAILED;
229
+ emit();
230
+ }
231
+ );
232
+ };
233
+ const openCancelFlow = (options = {}) => {
234
+ if (cancel) return cancel.promise;
235
+ const flow = config?.cancelFlow ?? PLAIN_CANCEL;
236
+ const plain = options.customer !== "business" || flow.reasons.length < 2;
237
+ let resolve = () => {
238
+ };
239
+ const promise = new Promise((r) => resolve = r);
240
+ const c = { flow, options, plain, screen: plain ? "confirm" : "survey", run: runId(), busy: false, resolve, promise };
241
+ cancel = c;
242
+ env.track(CANCEL_EVENT, { action: "shown", run: c.run, version: flow.version, customer: options.customer ?? "consumer", plain });
243
+ emit();
244
+ return promise;
245
+ };
246
+ const controller = {
247
+ setConfig: (c) => {
248
+ config = c;
249
+ if (active && !flowById(active)) active = null;
250
+ emit();
251
+ },
252
+ refresh,
253
+ onEvent: (name) => {
254
+ if (!name || name.startsWith("$")) return;
255
+ const flow = flowById(active);
256
+ let ticked = false;
257
+ if (flow) {
258
+ const p = prog(flow.id);
259
+ for (const s of flow.steps)
260
+ if (s.kind === "checklist" && s.event && !p.done.has(s.id) && matchesTrigger(s.event, name)) {
261
+ p.done.add(s.id);
262
+ ticked = true;
263
+ env.track(ONBOARDING_EVENT, { flow: flow.id, step: s.id, kind: s.kind, action: "completed" });
264
+ }
265
+ }
266
+ if (ticked) {
267
+ save();
268
+ emit();
269
+ }
270
+ if (!auto || !env.personId() && !env.storage?.()) return;
271
+ const next = flows().find((f) => f.steps.length && matchesTrigger(f.trigger, name) && !finished(f));
272
+ if (next) start(next);
273
+ },
274
+ openCancelFlow,
275
+ startOnboarding: (id) => {
276
+ const f = id ? flowById(id) : flows().find((x) => x.steps.length && !finished(x));
277
+ if (!f || !f.steps.length) return false;
278
+ const p = prog(f.id);
279
+ p.dismissed = false;
280
+ return start(f);
281
+ },
282
+ view,
283
+ subscribe: (l) => {
284
+ listeners.add(l);
285
+ return () => listeners.delete(l);
286
+ },
287
+ cancel: {
288
+ pick: (reason) => {
289
+ if (!cancel || cancel.busy || !cancel.flow.reasons.some((r) => r.id === reason)) return;
290
+ cancel.reason = reason;
291
+ cancel.error = void 0;
292
+ emit();
293
+ },
294
+ next: () => {
295
+ const c = cancel;
296
+ if (!c || c.screen !== "survey" || !c.reason) return;
297
+ env.track(CANCEL_EVENT, { action: "answered", run: c.run, version: c.flow.version, reason: c.reason });
298
+ c.screen = c.flow.offers[c.reason] ? "offer" : "confirm";
299
+ emit();
300
+ },
301
+ accept: (comment) => {
302
+ const c = cancel;
303
+ if (!c || c.screen !== "offer" || !c.reason) return;
304
+ const offer = c.flow.offers[c.reason];
305
+ if (!c.busy)
306
+ env.track(CANCEL_EVENT, {
307
+ action: "offer",
308
+ run: c.run,
309
+ version: c.flow.version,
310
+ reason: c.reason,
311
+ offer: offer?.kind,
312
+ outcome: "accepted"
313
+ });
314
+ settle(c, "saved", comment);
315
+ },
316
+ decline: () => {
317
+ const c = cancel;
318
+ if (!c || c.screen !== "offer" || c.busy) return;
319
+ env.track(CANCEL_EVENT, {
320
+ action: "offer",
321
+ run: c.run,
322
+ version: c.flow.version,
323
+ reason: c.reason,
324
+ offer: c.reason ? c.flow.offers[c.reason]?.kind : void 0,
325
+ outcome: "declined"
326
+ });
327
+ c.screen = "confirm";
328
+ c.error = void 0;
329
+ emit();
330
+ },
331
+ confirm: (comment) => {
332
+ const c = cancel;
333
+ if (!c || c.screen !== "confirm") return;
334
+ settle(c, "cancelled", comment);
335
+ },
336
+ close: () => {
337
+ const c = cancel;
338
+ if (!c || c.busy) return;
339
+ const r = result(c, "kept");
340
+ c.options.onClose?.(r);
341
+ finish(c, r);
342
+ }
343
+ },
344
+ onboarding: {
345
+ next: () => {
346
+ const v = onboardingView();
347
+ if (v?.type !== "step") return;
348
+ prog(v.flow.id).done.add(v.step.id);
349
+ env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: v.step.id, kind: v.step.kind, action: "completed" });
350
+ save();
351
+ emit();
352
+ },
353
+ check: (stepId) => {
354
+ const v = onboardingView();
355
+ if (v?.type !== "checklist") return;
356
+ const item = v.items.find((i) => i.step.id === stepId);
357
+ if (!item || item.done) return;
358
+ prog(v.flow.id).done.add(stepId);
359
+ env.track(ONBOARDING_EVENT, { flow: v.flow.id, step: stepId, kind: "checklist", action: "completed" });
360
+ save();
361
+ emit();
362
+ },
363
+ dismiss: () => {
364
+ const v = onboardingView();
365
+ if (!v || v.type !== "step" && v.type !== "checklist") return;
366
+ prog(v.flow.id).dismissed = true;
367
+ env.track(ONBOARDING_EVENT, { flow: v.flow.id, ...v.type === "step" ? { step: v.step.id } : {}, action: "dismissed" });
368
+ active = null;
369
+ save();
370
+ emit();
371
+ }
372
+ },
373
+ message: {
374
+ click: () => {
375
+ const v = view();
376
+ if (v?.type !== "message") return;
377
+ handled.add(v.message.id);
378
+ env.track(INAPP_EVENT, { message: v.message.id, journey: v.message.journey, action: "clicked" });
379
+ const url = safeUrl(v.message.url);
380
+ emit();
381
+ if (url) env.openUrl?.(url);
382
+ },
383
+ dismiss: () => {
384
+ const v = view();
385
+ if (v?.type !== "message") return;
386
+ handled.add(v.message.id);
387
+ env.track(INAPP_EVENT, { message: v.message.id, journey: v.message.journey, action: "dismissed" });
388
+ emit();
389
+ }
390
+ }
391
+ };
392
+ return controller;
393
+ };
394
+ export {
395
+ CANCEL_EVENT,
396
+ INAPP_EVENT,
397
+ ONBOARDING_EVENT,
398
+ PLAIN_CANCEL,
399
+ createFlows,
400
+ discounted,
401
+ eventSlug,
402
+ matchesTrigger,
403
+ money,
404
+ offerButton,
405
+ runId,
406
+ safeUrl,
407
+ startsOnLoad
408
+ };
409
+ //# sourceMappingURL=flows.js.map