@churnkey/react 0.1.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/LICENSE +19 -0
- package/README.md +326 -0
- package/dist/chunk-CPBEP4NW.cjs +1059 -0
- package/dist/chunk-CPBEP4NW.cjs.map +1 -0
- package/dist/chunk-GCQ75J4G.js +102 -0
- package/dist/chunk-GCQ75J4G.js.map +1 -0
- package/dist/chunk-IFVMM2LB.js +1054 -0
- package/dist/chunk-IFVMM2LB.js.map +1 -0
- package/dist/chunk-QTMZI5I2.js +85 -0
- package/dist/chunk-QTMZI5I2.js.map +1 -0
- package/dist/chunk-SIYJ4R4B.cjs +113 -0
- package/dist/chunk-SIYJ4R4B.cjs.map +1 -0
- package/dist/chunk-XU7KDCXO.cjs +88 -0
- package/dist/chunk-XU7KDCXO.cjs.map +1 -0
- package/dist/core.cjs +49 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +298 -0
- package/dist/core.d.ts +298 -0
- package/dist/core.js +4 -0
- package/dist/core.js.map +1 -0
- package/dist/headless.cjs +13 -0
- package/dist/headless.cjs.map +1 -0
- package/dist/headless.d.cts +30 -0
- package/dist/headless.d.ts +30 -0
- package/dist/headless.js +4 -0
- package/dist/headless.js.map +1 -0
- package/dist/index.cjs +960 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +39 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +901 -0
- package/dist/index.js.map +1 -0
- package/dist/step-graph-ChdI-VXV.d.cts +540 -0
- package/dist/step-graph-ChdI-VXV.d.ts +540 -0
- package/dist/styles.css +760 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
// src/core/api.ts
|
|
2
|
+
var DEFAULT_BASE_URL = "https://api.churnkey.co/v1";
|
|
3
|
+
var ChurnkeyApi = class {
|
|
4
|
+
creds;
|
|
5
|
+
baseUrl;
|
|
6
|
+
constructor(creds, baseUrl) {
|
|
7
|
+
this.creds = creds;
|
|
8
|
+
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
|
|
9
|
+
}
|
|
10
|
+
get headers() {
|
|
11
|
+
const h = {
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
"x-ck-app": this.creds.appId,
|
|
14
|
+
"x-ck-customer": this.creds.customerId,
|
|
15
|
+
"x-ck-authorization": this.creds.authHash,
|
|
16
|
+
"x-ck-mode": this.creds.mode
|
|
17
|
+
};
|
|
18
|
+
if (this.creds.subscriptionId) {
|
|
19
|
+
h["x-ck-subscription"] = this.creds.subscriptionId;
|
|
20
|
+
}
|
|
21
|
+
return h;
|
|
22
|
+
}
|
|
23
|
+
orgUrl(path) {
|
|
24
|
+
return `${this.baseUrl}/api/orgs/${this.creds.appId}/${path}`;
|
|
25
|
+
}
|
|
26
|
+
// Action methods discard the response body, so don't parse JSON here —
|
|
27
|
+
// a server returning an empty 201 would otherwise blow up res.json() and
|
|
28
|
+
// surface as an error even though the action committed.
|
|
29
|
+
async request(url, body) {
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: this.headers,
|
|
33
|
+
body: body ? JSON.stringify(body) : void 0
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const text = await res.text().catch(() => "");
|
|
37
|
+
throw new Error(`Churnkey API error (${res.status}): ${text}`);
|
|
38
|
+
}
|
|
39
|
+
return res;
|
|
40
|
+
}
|
|
41
|
+
async fetchConfig() {
|
|
42
|
+
const res = await this.request(this.orgUrl("cancel-flow/config"));
|
|
43
|
+
return await res.json();
|
|
44
|
+
}
|
|
45
|
+
async applyDiscount(couponId, blueprintId) {
|
|
46
|
+
await this.request(this.orgUrl("cancel-flow/actions/discount"), {
|
|
47
|
+
coupon: couponId,
|
|
48
|
+
blueprintId
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async pause(params) {
|
|
52
|
+
await this.request(this.orgUrl("cancel-flow/actions/pause"), {
|
|
53
|
+
duration: params.duration,
|
|
54
|
+
interval: params.interval
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async cancelSubscription() {
|
|
58
|
+
await this.request(this.orgUrl("cancel-flow/actions/cancel"), { cancelAtPeriodEnd: true });
|
|
59
|
+
}
|
|
60
|
+
async changePlan(planId) {
|
|
61
|
+
await this.request(this.orgUrl("cancel-flow/actions/change-plan"), { planId });
|
|
62
|
+
}
|
|
63
|
+
async extendTrial(days, blueprintId) {
|
|
64
|
+
await this.request(this.orgUrl("cancel-flow/actions/extend-trial"), { days, blueprintId });
|
|
65
|
+
}
|
|
66
|
+
async createSession(payload) {
|
|
67
|
+
await this.request(`${this.baseUrl}/api/sessions/sdk`, payload);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var AnalyticsClient = class {
|
|
71
|
+
appId;
|
|
72
|
+
baseUrl;
|
|
73
|
+
constructor(appId, baseUrl) {
|
|
74
|
+
this.appId = appId;
|
|
75
|
+
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
|
|
76
|
+
}
|
|
77
|
+
// Fire-and-forget. Non-2xx responses are ignored — analytics failures
|
|
78
|
+
// should never surface into the host app's flow.
|
|
79
|
+
async createSession(payload) {
|
|
80
|
+
await fetch(`${this.baseUrl}/api/sessions/sdk`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"x-ck-app": this.appId
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify(payload)
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
function toIsoString(value) {
|
|
91
|
+
if (value == null) return void 0;
|
|
92
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
93
|
+
}
|
|
94
|
+
function toApiBillingInterval(interval) {
|
|
95
|
+
if (!interval) return void 0;
|
|
96
|
+
return interval.toUpperCase();
|
|
97
|
+
}
|
|
98
|
+
function directDataToSessionCustomer(customer, subscriptions) {
|
|
99
|
+
const sub = subscriptions?.[0];
|
|
100
|
+
const price = sub?.items[0]?.price;
|
|
101
|
+
return {
|
|
102
|
+
id: customer.id,
|
|
103
|
+
email: customer.email,
|
|
104
|
+
subscriptionId: sub?.id,
|
|
105
|
+
planId: price?.id,
|
|
106
|
+
planPrice: price?.amount.value,
|
|
107
|
+
currency: price?.amount.currency ?? customer.currency,
|
|
108
|
+
billingInterval: toApiBillingInterval(price?.duration?.interval),
|
|
109
|
+
created: toIsoString(sub?.start),
|
|
110
|
+
onTrial: sub ? sub.status.name === "trial" : void 0,
|
|
111
|
+
customAttributes: customer.metadata
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/core/merge-fields.ts
|
|
116
|
+
var MERGE_FIELD_PATTERN = /{{([a-zA-Z0-9_.]+)\|([^}]*)}}/g;
|
|
117
|
+
function buildMergeAttrs(customer) {
|
|
118
|
+
if (!customer) return {};
|
|
119
|
+
const attrs = {};
|
|
120
|
+
const first = customer.name;
|
|
121
|
+
const last = customer.lastName;
|
|
122
|
+
const fullName = [first, last].filter(Boolean).join(" ") || void 0;
|
|
123
|
+
if (fullName) attrs.CUSTOMER_NAME = fullName;
|
|
124
|
+
if (first) attrs["CUSTOMER_NAME.FIRST"] = first;
|
|
125
|
+
if (last) attrs["CUSTOMER_NAME.LAST"] = last;
|
|
126
|
+
if (customer.email) attrs.CUSTOMER_EMAIL = customer.email;
|
|
127
|
+
for (const [key, value] of Object.entries(customer.metadata ?? {})) {
|
|
128
|
+
if (value != null) attrs[key] = String(value);
|
|
129
|
+
}
|
|
130
|
+
return attrs;
|
|
131
|
+
}
|
|
132
|
+
function applyMergeFields(text, attrs) {
|
|
133
|
+
if (!text?.includes("{{")) return text;
|
|
134
|
+
return text.replace(MERGE_FIELD_PATTERN, (_match, field, fallback) => attrs[field] ?? fallback);
|
|
135
|
+
}
|
|
136
|
+
function applyMergeFieldsToSteps(steps, customer) {
|
|
137
|
+
const attrs = buildMergeAttrs(customer);
|
|
138
|
+
return steps.map((step) => mergeStep(step, attrs));
|
|
139
|
+
}
|
|
140
|
+
function mergeStep(step, attrs) {
|
|
141
|
+
switch (step.type) {
|
|
142
|
+
case "survey": {
|
|
143
|
+
const s = step;
|
|
144
|
+
return {
|
|
145
|
+
...s,
|
|
146
|
+
title: maybeMerge(s.title, attrs),
|
|
147
|
+
description: maybeMerge(s.description, attrs),
|
|
148
|
+
reasons: s.reasons.map((r) => mergeReason(r, attrs))
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
case "offer": {
|
|
152
|
+
const s = step;
|
|
153
|
+
return {
|
|
154
|
+
...s,
|
|
155
|
+
title: maybeMerge(s.title, attrs),
|
|
156
|
+
description: maybeMerge(s.description, attrs),
|
|
157
|
+
offer: s.offer ? mergeOfferDecision(s.offer, attrs) : void 0
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
case "feedback": {
|
|
161
|
+
const s = step;
|
|
162
|
+
return {
|
|
163
|
+
...s,
|
|
164
|
+
title: maybeMerge(s.title, attrs),
|
|
165
|
+
description: maybeMerge(s.description, attrs),
|
|
166
|
+
placeholder: maybeMerge(s.placeholder, attrs)
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
case "success":
|
|
170
|
+
return step;
|
|
171
|
+
default: {
|
|
172
|
+
const s = step;
|
|
173
|
+
return { ...step, title: maybeMerge(s.title, attrs), description: maybeMerge(s.description, attrs) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function mergeReason(r, attrs) {
|
|
178
|
+
return { ...r, label: applyMergeFields(r.label, attrs) };
|
|
179
|
+
}
|
|
180
|
+
function mergeOfferDecision(o, attrs) {
|
|
181
|
+
return {
|
|
182
|
+
...o,
|
|
183
|
+
copy: {
|
|
184
|
+
headline: applyMergeFields(o.copy.headline, attrs),
|
|
185
|
+
body: applyMergeFields(o.copy.body, attrs),
|
|
186
|
+
cta: applyMergeFields(o.copy.cta, attrs),
|
|
187
|
+
declineCta: applyMergeFields(o.copy.declineCta, attrs)
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function maybeMerge(text, attrs) {
|
|
192
|
+
return text ? applyMergeFields(text, attrs) : text;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/core/step-graph.ts
|
|
196
|
+
var EMPTY_GRAPH = { stepMap: {}, firstStepId: "", orderedStepIds: [] };
|
|
197
|
+
function buildStepGraph(steps, defaultOfferCopy2) {
|
|
198
|
+
if (steps.length === 0) return EMPTY_GRAPH;
|
|
199
|
+
const resolved = steps.map((step, i) => normalizeStep(step, i));
|
|
200
|
+
linkNeighbors(resolved);
|
|
201
|
+
const stepMap = {};
|
|
202
|
+
for (const step of resolved) stepMap[step.guid] = step;
|
|
203
|
+
const surveyStep = resolved.find((s) => s.type === "survey");
|
|
204
|
+
if (surveyStep?.reasons) {
|
|
205
|
+
surveyStep.offersAttached = {};
|
|
206
|
+
for (const reason of surveyStep.reasons) {
|
|
207
|
+
if (!reason.offer) continue;
|
|
208
|
+
const offerGuid = `${surveyStep.guid}:offer:${reason.id}`;
|
|
209
|
+
stepMap[offerGuid] = {
|
|
210
|
+
guid: offerGuid,
|
|
211
|
+
type: "offer",
|
|
212
|
+
surveyOffer: true,
|
|
213
|
+
offer: toDecision(reason.offer, defaultOfferCopy2),
|
|
214
|
+
// Decline/next from the synthetic offer skips the survey entirely;
|
|
215
|
+
// back returns to the survey so the user can pick a different reason.
|
|
216
|
+
defaultNextStep: surveyStep.defaultNextStep,
|
|
217
|
+
defaultPreviousStep: surveyStep.guid
|
|
218
|
+
};
|
|
219
|
+
surveyStep.offersAttached[reason.id] = offerGuid;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
stepMap,
|
|
224
|
+
firstStepId: resolved[0].guid,
|
|
225
|
+
orderedStepIds: resolved.map((s) => s.guid),
|
|
226
|
+
surveyStepId: surveyStep?.guid
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function normalizeStep(step, index) {
|
|
230
|
+
const guid = step.guid ?? `step-${index}-${step.type}`;
|
|
231
|
+
const base = { guid, type: step.type };
|
|
232
|
+
switch (step.type) {
|
|
233
|
+
case "survey": {
|
|
234
|
+
const s = step;
|
|
235
|
+
return {
|
|
236
|
+
...base,
|
|
237
|
+
title: s.title,
|
|
238
|
+
description: s.description,
|
|
239
|
+
reasons: s.reasons,
|
|
240
|
+
numChoices: s.reasons.length,
|
|
241
|
+
classNames: s.classNames
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
case "offer": {
|
|
245
|
+
const s = step;
|
|
246
|
+
return {
|
|
247
|
+
...base,
|
|
248
|
+
title: s.title,
|
|
249
|
+
description: s.description,
|
|
250
|
+
offer: s.offer,
|
|
251
|
+
classNames: s.classNames
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
case "feedback": {
|
|
255
|
+
const s = step;
|
|
256
|
+
return {
|
|
257
|
+
...base,
|
|
258
|
+
title: s.title,
|
|
259
|
+
description: s.description,
|
|
260
|
+
placeholder: s.placeholder,
|
|
261
|
+
required: s.required,
|
|
262
|
+
minLength: s.minLength,
|
|
263
|
+
classNames: s.classNames
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
case "confirm": {
|
|
267
|
+
const s = step;
|
|
268
|
+
return {
|
|
269
|
+
...base,
|
|
270
|
+
title: s.title,
|
|
271
|
+
description: s.description,
|
|
272
|
+
classNames: s.classNames
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
case "success": {
|
|
276
|
+
const s = step;
|
|
277
|
+
return {
|
|
278
|
+
...base,
|
|
279
|
+
savedTitle: s.savedTitle,
|
|
280
|
+
savedDescription: s.savedDescription,
|
|
281
|
+
cancelledTitle: s.cancelledTitle,
|
|
282
|
+
cancelledDescription: s.cancelledDescription,
|
|
283
|
+
classNames: s.classNames
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
default: {
|
|
287
|
+
const s = step;
|
|
288
|
+
return {
|
|
289
|
+
...base,
|
|
290
|
+
title: s.title,
|
|
291
|
+
description: s.description,
|
|
292
|
+
data: s.data
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function linkNeighbors(steps) {
|
|
298
|
+
for (let i = 0; i < steps.length; i++) {
|
|
299
|
+
if (i < steps.length - 1) steps[i].defaultNextStep = steps[i + 1].guid;
|
|
300
|
+
if (i > 0) steps[i].defaultPreviousStep = steps[i - 1].guid;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function toDecision(offer, defaultCopy) {
|
|
304
|
+
return "copy" in offer ? offer : { ...offer, copy: defaultCopy(offer) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/core/transform.ts
|
|
308
|
+
function transformSdkConfig(config) {
|
|
309
|
+
return {
|
|
310
|
+
steps: config.steps.map(transformStep),
|
|
311
|
+
blueprintId: config.blueprintId,
|
|
312
|
+
metadata: {
|
|
313
|
+
autoOptimizationKey: config.autoOptimizationKey
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function transformStep(step) {
|
|
318
|
+
switch (step.type) {
|
|
319
|
+
case "survey":
|
|
320
|
+
return {
|
|
321
|
+
type: "survey",
|
|
322
|
+
guid: step.guid,
|
|
323
|
+
title: step.title,
|
|
324
|
+
description: step.description,
|
|
325
|
+
reasons: step.reasons.map(transformReason)
|
|
326
|
+
};
|
|
327
|
+
case "offer":
|
|
328
|
+
return {
|
|
329
|
+
type: "offer",
|
|
330
|
+
guid: step.guid,
|
|
331
|
+
title: step.title,
|
|
332
|
+
description: step.description,
|
|
333
|
+
offer: transformOfferDecision(step.offer)
|
|
334
|
+
};
|
|
335
|
+
case "feedback":
|
|
336
|
+
return {
|
|
337
|
+
type: "feedback",
|
|
338
|
+
guid: step.guid,
|
|
339
|
+
title: step.title,
|
|
340
|
+
description: step.description,
|
|
341
|
+
placeholder: step.placeholder,
|
|
342
|
+
required: step.required,
|
|
343
|
+
minLength: step.minLength
|
|
344
|
+
};
|
|
345
|
+
case "confirm":
|
|
346
|
+
return {
|
|
347
|
+
type: "confirm",
|
|
348
|
+
guid: step.guid,
|
|
349
|
+
title: step.title,
|
|
350
|
+
description: step.description
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function transformReason(r) {
|
|
355
|
+
const out = {
|
|
356
|
+
id: r.id,
|
|
357
|
+
label: r.label,
|
|
358
|
+
freeform: r.freeform
|
|
359
|
+
};
|
|
360
|
+
if (r.offer) out.offer = transformOfferConfig(r.offer);
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
function transformOfferDecision(o) {
|
|
364
|
+
return { ...transformOfferConfig(o), copy: o.copy, decisionId: o.decisionId };
|
|
365
|
+
}
|
|
366
|
+
function transformOfferConfig(o) {
|
|
367
|
+
switch (o.type) {
|
|
368
|
+
case "discount": {
|
|
369
|
+
const d = o;
|
|
370
|
+
return {
|
|
371
|
+
type: "discount",
|
|
372
|
+
couponId: d.couponId,
|
|
373
|
+
percentOff: d.percentOff,
|
|
374
|
+
amountOff: d.amountOff,
|
|
375
|
+
currency: d.currency,
|
|
376
|
+
durationInMonths: d.durationInMonths
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
case "pause":
|
|
380
|
+
return {
|
|
381
|
+
type: "pause",
|
|
382
|
+
months: o.months,
|
|
383
|
+
interval: o.interval,
|
|
384
|
+
datePicker: o.datePicker
|
|
385
|
+
};
|
|
386
|
+
case "plan_change":
|
|
387
|
+
return {
|
|
388
|
+
type: "plan_change",
|
|
389
|
+
plans: o.plans
|
|
390
|
+
};
|
|
391
|
+
case "trial_extension":
|
|
392
|
+
return {
|
|
393
|
+
type: "trial_extension",
|
|
394
|
+
days: o.days
|
|
395
|
+
};
|
|
396
|
+
case "redirect":
|
|
397
|
+
return {
|
|
398
|
+
type: "redirect",
|
|
399
|
+
url: o.url,
|
|
400
|
+
label: o.label ?? ""
|
|
401
|
+
};
|
|
402
|
+
case "contact":
|
|
403
|
+
return {
|
|
404
|
+
type: "contact",
|
|
405
|
+
url: o.url,
|
|
406
|
+
label: o.label
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function defaultOfferCopy(offer) {
|
|
411
|
+
const o = offer;
|
|
412
|
+
switch (o.type) {
|
|
413
|
+
case "discount":
|
|
414
|
+
return {
|
|
415
|
+
headline: o.percentOff != null ? `How about ${o.percentOff}% off?` : "Special offer",
|
|
416
|
+
body: discountBody(o),
|
|
417
|
+
cta: "Accept offer",
|
|
418
|
+
declineCta: "No thanks"
|
|
419
|
+
};
|
|
420
|
+
case "pause":
|
|
421
|
+
return {
|
|
422
|
+
headline: "Take a break instead?",
|
|
423
|
+
body: `Pause your subscription for up to ${o.months} month${o.months === 1 ? "" : "s"}.`,
|
|
424
|
+
cta: "Pause subscription",
|
|
425
|
+
declineCta: "No thanks"
|
|
426
|
+
};
|
|
427
|
+
case "plan_change":
|
|
428
|
+
return {
|
|
429
|
+
headline: "Switch to a different plan?",
|
|
430
|
+
body: "We have other plans that might be a better fit.",
|
|
431
|
+
cta: "Switch plan",
|
|
432
|
+
declineCta: "No thanks"
|
|
433
|
+
};
|
|
434
|
+
case "trial_extension":
|
|
435
|
+
return {
|
|
436
|
+
headline: "Need more time?",
|
|
437
|
+
body: `We'll extend your trial by ${o.days} day${o.days === 1 ? "" : "s"}.`,
|
|
438
|
+
cta: "Extend trial",
|
|
439
|
+
declineCta: "No thanks"
|
|
440
|
+
};
|
|
441
|
+
case "contact":
|
|
442
|
+
return {
|
|
443
|
+
headline: "Talk to us first?",
|
|
444
|
+
body: "Our team would love to help resolve any issues.",
|
|
445
|
+
cta: o.label ?? "Contact support",
|
|
446
|
+
declineCta: "No thanks"
|
|
447
|
+
};
|
|
448
|
+
case "redirect":
|
|
449
|
+
return {
|
|
450
|
+
headline: "Before you go...",
|
|
451
|
+
body: "Check this out \u2014 it might change your mind.",
|
|
452
|
+
cta: o.label,
|
|
453
|
+
declineCta: "No thanks"
|
|
454
|
+
};
|
|
455
|
+
default:
|
|
456
|
+
return {
|
|
457
|
+
headline: "Before you go...",
|
|
458
|
+
body: "We'd like to offer you something.",
|
|
459
|
+
cta: "Accept",
|
|
460
|
+
declineCta: "No thanks"
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function discountBody(o) {
|
|
465
|
+
if (o.percentOff == null) return "We'd like to offer you a discount.";
|
|
466
|
+
const months = o.durationInMonths ?? 1;
|
|
467
|
+
return `We'd like to offer you ${o.percentOff}% off for ${months} month${months === 1 ? "" : "s"}.`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/core/machine.ts
|
|
471
|
+
function handlerFor(offerType, cb) {
|
|
472
|
+
switch (offerType) {
|
|
473
|
+
case "discount":
|
|
474
|
+
return cb.handleDiscount;
|
|
475
|
+
case "pause":
|
|
476
|
+
return cb.handlePause;
|
|
477
|
+
case "plan_change":
|
|
478
|
+
return cb.handlePlanChange;
|
|
479
|
+
case "trial_extension":
|
|
480
|
+
return cb.handleTrialExtension;
|
|
481
|
+
default:
|
|
482
|
+
return void 0;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function listenerFor(offerType, cb) {
|
|
486
|
+
switch (offerType) {
|
|
487
|
+
case "discount":
|
|
488
|
+
return cb.onDiscount;
|
|
489
|
+
case "pause":
|
|
490
|
+
return cb.onPause;
|
|
491
|
+
case "plan_change":
|
|
492
|
+
return cb.onPlanChange;
|
|
493
|
+
case "trial_extension":
|
|
494
|
+
return cb.onTrialExtension;
|
|
495
|
+
default:
|
|
496
|
+
return void 0;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function runListener(listener, offer, customer) {
|
|
500
|
+
return Promise.resolve(listener(offer, customer)).catch((e) => {
|
|
501
|
+
console.error("Error in offer listener:", e);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
function isPlainObject(value) {
|
|
505
|
+
if (!value || typeof value !== "object") return false;
|
|
506
|
+
const proto = Object.getPrototypeOf(value);
|
|
507
|
+
return proto === null || proto === Object.prototype;
|
|
508
|
+
}
|
|
509
|
+
var STEP_TYPE_API_MAP = {
|
|
510
|
+
survey: "SURVEY",
|
|
511
|
+
offer: "OFFER",
|
|
512
|
+
feedback: "FREEFORM",
|
|
513
|
+
confirm: "CONFIRM"
|
|
514
|
+
};
|
|
515
|
+
var OFFER_TYPE_API_MAP = {
|
|
516
|
+
discount: "DISCOUNT",
|
|
517
|
+
pause: "PAUSE",
|
|
518
|
+
plan_change: "PLAN_CHANGE",
|
|
519
|
+
trial_extension: "TRIAL_EXTENSION",
|
|
520
|
+
contact: "CONTACT",
|
|
521
|
+
redirect: "REDIRECT"
|
|
522
|
+
};
|
|
523
|
+
function toApiStepType(step) {
|
|
524
|
+
if (step === "success") return null;
|
|
525
|
+
const builtIn = STEP_TYPE_API_MAP[step];
|
|
526
|
+
if (builtIn) return { stepType: builtIn };
|
|
527
|
+
return { stepType: "CUSTOM", customStepType: step };
|
|
528
|
+
}
|
|
529
|
+
function toApiOfferType(type) {
|
|
530
|
+
const builtIn = OFFER_TYPE_API_MAP[type];
|
|
531
|
+
if (builtIn) return { offerType: builtIn };
|
|
532
|
+
return { offerType: "CUSTOM", customOfferType: type };
|
|
533
|
+
}
|
|
534
|
+
function toApiPauseInterval(interval) {
|
|
535
|
+
return interval === "week" ? "WEEK" : "MONTH";
|
|
536
|
+
}
|
|
537
|
+
function toPresentedOfferConfig(rec) {
|
|
538
|
+
const base = toApiOfferType(rec.type);
|
|
539
|
+
if (base.offerType === "CUSTOM") return base;
|
|
540
|
+
const o = rec;
|
|
541
|
+
switch (o.type) {
|
|
542
|
+
case "discount":
|
|
543
|
+
return {
|
|
544
|
+
...base,
|
|
545
|
+
discountConfig: { couponId: o.couponId, customAmount: o.percentOff }
|
|
546
|
+
};
|
|
547
|
+
case "pause":
|
|
548
|
+
return {
|
|
549
|
+
...base,
|
|
550
|
+
pauseConfig: {
|
|
551
|
+
maxPauseLength: o.months,
|
|
552
|
+
pauseInterval: toApiPauseInterval(o.interval)
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
case "plan_change":
|
|
556
|
+
return { ...base, planChangeConfig: {} };
|
|
557
|
+
case "trial_extension":
|
|
558
|
+
return { ...base, trialExtensionConfig: { trialExtensionDays: o.days } };
|
|
559
|
+
case "redirect":
|
|
560
|
+
return {
|
|
561
|
+
...base,
|
|
562
|
+
redirectConfig: { redirectUrl: o.url, redirectLabel: o.label }
|
|
563
|
+
};
|
|
564
|
+
case "contact":
|
|
565
|
+
return { ...base, contactConfig: {} };
|
|
566
|
+
default:
|
|
567
|
+
return base;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function toAcceptedOfferPayload(rec, result) {
|
|
571
|
+
const base = { guid: rec.decisionId, ...toApiOfferType(rec.type) };
|
|
572
|
+
if (base.offerType === "CUSTOM") {
|
|
573
|
+
return result ? { ...base, customOfferResult: result } : base;
|
|
574
|
+
}
|
|
575
|
+
const o = rec;
|
|
576
|
+
switch (o.type) {
|
|
577
|
+
case "discount":
|
|
578
|
+
return {
|
|
579
|
+
...base,
|
|
580
|
+
couponId: o.couponId,
|
|
581
|
+
couponAmount: o.percentOff,
|
|
582
|
+
couponType: "PERCENT",
|
|
583
|
+
couponDuration: o.durationInMonths
|
|
584
|
+
};
|
|
585
|
+
case "pause":
|
|
586
|
+
return {
|
|
587
|
+
...base,
|
|
588
|
+
pauseDuration: o.months,
|
|
589
|
+
pauseInterval: toApiPauseInterval(o.interval)
|
|
590
|
+
};
|
|
591
|
+
case "plan_change":
|
|
592
|
+
return {
|
|
593
|
+
...base,
|
|
594
|
+
newPlanId: o.plans?.[0]?.id,
|
|
595
|
+
newPlanPrice: o.plans?.[0]?.amount.value
|
|
596
|
+
};
|
|
597
|
+
case "trial_extension":
|
|
598
|
+
return { ...base, trialExtensionDays: o.days };
|
|
599
|
+
case "redirect":
|
|
600
|
+
return { ...base, redirectUrl: o.url };
|
|
601
|
+
default:
|
|
602
|
+
return base;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
var CancelFlowMachine = class {
|
|
606
|
+
state;
|
|
607
|
+
cachedSnapshot;
|
|
608
|
+
callbacks;
|
|
609
|
+
graph = { stepMap: {}, firstStepId: "", orderedStepIds: [] };
|
|
610
|
+
listeners = /* @__PURE__ */ new Set();
|
|
611
|
+
apiClient = null;
|
|
612
|
+
analyticsClient = null;
|
|
613
|
+
directCustomer = null;
|
|
614
|
+
directSubscriptions = null;
|
|
615
|
+
creds = null;
|
|
616
|
+
config = null;
|
|
617
|
+
blueprintId = null;
|
|
618
|
+
localSteps = null;
|
|
619
|
+
stepsViewed = [];
|
|
620
|
+
presentedOffers = [];
|
|
621
|
+
customStepResults = {};
|
|
622
|
+
configMode = "live";
|
|
623
|
+
stepEnteredAt = Date.now();
|
|
624
|
+
aborted = false;
|
|
625
|
+
// Visited-step stack. `back` pops this so it lands on the actually-seen
|
|
626
|
+
// previous step (including synthetic offers a survey choice spawned),
|
|
627
|
+
// not the previous step in declared graph order.
|
|
628
|
+
history = [];
|
|
629
|
+
constructor(config) {
|
|
630
|
+
this.callbacks = config;
|
|
631
|
+
if (config.mode) this.configMode = config.mode;
|
|
632
|
+
if (config.appId && config.customer) {
|
|
633
|
+
this.analyticsClient = new AnalyticsClient(config.appId, config.apiBaseUrl);
|
|
634
|
+
this.directCustomer = config.customer;
|
|
635
|
+
this.directSubscriptions = config.subscriptions ?? null;
|
|
636
|
+
}
|
|
637
|
+
if (config.session) {
|
|
638
|
+
if (config.steps) this.localSteps = config.steps;
|
|
639
|
+
} else if (config.steps) {
|
|
640
|
+
const merged = applyMergeFieldsToSteps(config.steps, this.directCustomer);
|
|
641
|
+
this.graph = buildStepGraph(merged, defaultOfferCopy);
|
|
642
|
+
}
|
|
643
|
+
this.state = this.buildInitialState(null);
|
|
644
|
+
this.cachedSnapshot = { ...this.state };
|
|
645
|
+
const first = this.firstStep();
|
|
646
|
+
if (!config.session && first) this.trackStepEnter(first);
|
|
647
|
+
}
|
|
648
|
+
// Public methods are arrow-bound so consumers can pass them directly
|
|
649
|
+
// (`<button onClick={flow.next}>`) and so useSyncExternalStore sees
|
|
650
|
+
// stable references between renders.
|
|
651
|
+
subscribe = (listener) => {
|
|
652
|
+
this.listeners.add(listener);
|
|
653
|
+
return () => {
|
|
654
|
+
this.listeners.delete(listener);
|
|
655
|
+
};
|
|
656
|
+
};
|
|
657
|
+
getSnapshot = () => {
|
|
658
|
+
return this.cachedSnapshot;
|
|
659
|
+
};
|
|
660
|
+
get reasons() {
|
|
661
|
+
return this.surveyStep()?.reasons ?? [];
|
|
662
|
+
}
|
|
663
|
+
get currentStep() {
|
|
664
|
+
return this.graph.stepMap[this.state.currentStepId];
|
|
665
|
+
}
|
|
666
|
+
/** The offer on the current step, or null. Always derived — no separate slot to drift. */
|
|
667
|
+
get currentOffer() {
|
|
668
|
+
return this.currentStep?.offer ?? null;
|
|
669
|
+
}
|
|
670
|
+
/** Whether `back()` would move anywhere — false on the first step and on success. */
|
|
671
|
+
get canGoBack() {
|
|
672
|
+
if (this.state.step === "success") return false;
|
|
673
|
+
return this.history.length > 0 || Boolean(this.currentStep?.defaultPreviousStep);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* First step of a given type. Fine for the common "one step per type" case;
|
|
677
|
+
* flows with multiple of a type should key on currentStepId instead.
|
|
678
|
+
*/
|
|
679
|
+
getStepConfig(stepType) {
|
|
680
|
+
return Object.values(this.graph.stepMap).find((s) => s.type === stepType);
|
|
681
|
+
}
|
|
682
|
+
/** Progress indicator index. Synthetic offers share their survey's slot. */
|
|
683
|
+
get stepIndex() {
|
|
684
|
+
const current = this.currentStep;
|
|
685
|
+
if (!current) return 0;
|
|
686
|
+
if (current.surveyOffer) {
|
|
687
|
+
const surveyId = current.defaultPreviousStep;
|
|
688
|
+
return surveyId ? this.graph.orderedStepIds.indexOf(surveyId) : 0;
|
|
689
|
+
}
|
|
690
|
+
return Math.max(0, this.graph.orderedStepIds.indexOf(current.guid));
|
|
691
|
+
}
|
|
692
|
+
get totalSteps() {
|
|
693
|
+
return this.graph.orderedStepIds.length;
|
|
694
|
+
}
|
|
695
|
+
selectReason = (id) => {
|
|
696
|
+
if (!this.reasons.find((r) => r.id === id)) return;
|
|
697
|
+
this.setState({ selectedReason: id });
|
|
698
|
+
};
|
|
699
|
+
next = (result) => {
|
|
700
|
+
if (isPlainObject(result)) {
|
|
701
|
+
this.customStepResults[this.state.step] = result;
|
|
702
|
+
}
|
|
703
|
+
const nextId = this.projectedNextStepId();
|
|
704
|
+
if (!nextId) return;
|
|
705
|
+
this.transitionTo(nextId, { recordHistory: true });
|
|
706
|
+
};
|
|
707
|
+
back = () => {
|
|
708
|
+
const popped = this.history.pop();
|
|
709
|
+
if (popped) {
|
|
710
|
+
this.transitionTo(popped);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const prevId = this.currentStep?.defaultPreviousStep;
|
|
714
|
+
if (prevId) this.transitionTo(prevId);
|
|
715
|
+
};
|
|
716
|
+
accept = async (result) => {
|
|
717
|
+
const offer = this.currentOffer;
|
|
718
|
+
if (!offer) return;
|
|
719
|
+
const safeResult = isPlainObject(result) ? result : void 0;
|
|
720
|
+
this.setState({ isProcessing: true, error: null });
|
|
721
|
+
try {
|
|
722
|
+
const acceptedOffer = this.buildAcceptedOffer(offer, safeResult);
|
|
723
|
+
const customer = this.state.customer;
|
|
724
|
+
const handler = handlerFor(offer.type, this.callbacks);
|
|
725
|
+
if (handler) {
|
|
726
|
+
await handler(acceptedOffer, customer);
|
|
727
|
+
} else if (this.isTokenMode()) {
|
|
728
|
+
await this.executeTokenAction(acceptedOffer);
|
|
729
|
+
}
|
|
730
|
+
const listener = listenerFor(offer.type, this.callbacks);
|
|
731
|
+
if (listener) await runListener(listener, acceptedOffer, customer);
|
|
732
|
+
await this.callbacks.onAccept?.(acceptedOffer, customer);
|
|
733
|
+
this.markCurrentOfferAccepted();
|
|
734
|
+
this.enterSuccessStep("saved");
|
|
735
|
+
this.recordOutcome("saved", offer, safeResult);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
this.setState({ isProcessing: false, error });
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
decline = () => {
|
|
741
|
+
this.markCurrentOfferDeclined();
|
|
742
|
+
const nextId = this.currentStep?.defaultNextStep;
|
|
743
|
+
if (nextId) this.transitionTo(nextId, { recordHistory: true });
|
|
744
|
+
};
|
|
745
|
+
setFeedback = (text) => {
|
|
746
|
+
this.setState({ feedback: text });
|
|
747
|
+
};
|
|
748
|
+
cancel = async () => {
|
|
749
|
+
this.setState({ isProcessing: true, error: null });
|
|
750
|
+
try {
|
|
751
|
+
const customer = this.state.customer;
|
|
752
|
+
if (this.callbacks.handleCancel) {
|
|
753
|
+
await this.callbacks.handleCancel(customer);
|
|
754
|
+
} else if (this.isTokenMode()) {
|
|
755
|
+
await this.apiClient.cancelSubscription();
|
|
756
|
+
}
|
|
757
|
+
await this.callbacks.onCancel?.(customer);
|
|
758
|
+
this.enterSuccessStep("cancelled");
|
|
759
|
+
this.recordOutcome("cancelled");
|
|
760
|
+
} catch (error) {
|
|
761
|
+
this.setState({ isProcessing: false, error });
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
close = () => {
|
|
765
|
+
if (this.state.outcome === null && !this.aborted) {
|
|
766
|
+
this.aborted = true;
|
|
767
|
+
this.recordAbort();
|
|
768
|
+
}
|
|
769
|
+
this.callbacks.onClose?.();
|
|
770
|
+
};
|
|
771
|
+
destroy() {
|
|
772
|
+
this.listeners.clear();
|
|
773
|
+
}
|
|
774
|
+
// Callbacks are wired through the constructor; this only takes the
|
|
775
|
+
// server-supplied config so callers can't accidentally clobber them.
|
|
776
|
+
initializeFromConfig(config, apiClient, creds) {
|
|
777
|
+
this.apiClient = apiClient;
|
|
778
|
+
this.creds = creds;
|
|
779
|
+
this.config = config;
|
|
780
|
+
const result = transformSdkConfig(config);
|
|
781
|
+
this.blueprintId = result.blueprintId;
|
|
782
|
+
const merged = this.localSteps ? mergeLocalSteps(result.steps, this.localSteps) : result.steps;
|
|
783
|
+
const steps = applyMergeFieldsToSteps(merged, config.customer ?? null);
|
|
784
|
+
this.graph = buildStepGraph(steps, defaultOfferCopy);
|
|
785
|
+
this.state = this.buildInitialState(config.customer ?? null);
|
|
786
|
+
this.cachedSnapshot = { ...this.state };
|
|
787
|
+
const first = this.firstStep();
|
|
788
|
+
if (first) this.trackStepEnter(first);
|
|
789
|
+
this.notify();
|
|
790
|
+
}
|
|
791
|
+
// --- Internal helpers ---
|
|
792
|
+
isTokenMode() {
|
|
793
|
+
return this.apiClient != null;
|
|
794
|
+
}
|
|
795
|
+
firstStep() {
|
|
796
|
+
return this.graph.stepMap[this.graph.firstStepId];
|
|
797
|
+
}
|
|
798
|
+
surveyStep() {
|
|
799
|
+
return this.graph.surveyStepId ? this.graph.stepMap[this.graph.surveyStepId] : void 0;
|
|
800
|
+
}
|
|
801
|
+
buildInitialState(customer) {
|
|
802
|
+
const first = this.firstStep();
|
|
803
|
+
return {
|
|
804
|
+
step: first?.type ?? "survey",
|
|
805
|
+
currentStepId: this.graph.firstStepId,
|
|
806
|
+
selectedReason: null,
|
|
807
|
+
feedback: "",
|
|
808
|
+
outcome: null,
|
|
809
|
+
isProcessing: false,
|
|
810
|
+
error: null,
|
|
811
|
+
customer
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
// On a survey step with a selected reason that has an attached offer,
|
|
815
|
+
// jump to that offer's synthetic step; otherwise follow the default
|
|
816
|
+
// forward pointer.
|
|
817
|
+
projectedNextStepId() {
|
|
818
|
+
const current = this.currentStep;
|
|
819
|
+
if (!current) return void 0;
|
|
820
|
+
if (current.offersAttached && this.state.selectedReason) {
|
|
821
|
+
const override = current.offersAttached[this.state.selectedReason];
|
|
822
|
+
if (override) return override;
|
|
823
|
+
}
|
|
824
|
+
return current.defaultNextStep;
|
|
825
|
+
}
|
|
826
|
+
// Single entry point for step changes. All navigation flows through here so
|
|
827
|
+
// view-timing and presentation tracking stay in one place. Forward callers
|
|
828
|
+
// pass recordHistory; `back` omits it so popping isn't reversed.
|
|
829
|
+
transitionTo(stepId, opts = {}) {
|
|
830
|
+
const step = this.graph.stepMap[stepId];
|
|
831
|
+
if (!step) return;
|
|
832
|
+
if (opts.recordHistory && this.state.currentStepId && this.state.currentStepId !== stepId) {
|
|
833
|
+
this.history.push(this.state.currentStepId);
|
|
834
|
+
}
|
|
835
|
+
this.setState({ step: step.type, currentStepId: stepId });
|
|
836
|
+
}
|
|
837
|
+
setState(partial) {
|
|
838
|
+
const prevStep = this.state.step;
|
|
839
|
+
this.state = { ...this.state, ...partial };
|
|
840
|
+
this.cachedSnapshot = { ...this.state };
|
|
841
|
+
if (partial.step && partial.step !== prevStep) {
|
|
842
|
+
this.finalizeStepView(prevStep);
|
|
843
|
+
const step = this.graph.stepMap[this.state.currentStepId];
|
|
844
|
+
if (step?.type === partial.step) this.trackStepEnter(step);
|
|
845
|
+
else this.trackStepView(partial.step);
|
|
846
|
+
this.callbacks.onStepChange?.(partial.step, prevStep);
|
|
847
|
+
}
|
|
848
|
+
this.notify();
|
|
849
|
+
}
|
|
850
|
+
trackStepEnter(step) {
|
|
851
|
+
this.trackStepView(step.type, step.guid, step.numChoices);
|
|
852
|
+
if (step.type === "offer") this.recordOfferPresented();
|
|
853
|
+
}
|
|
854
|
+
recordOfferPresented() {
|
|
855
|
+
const offer = this.currentOffer;
|
|
856
|
+
if (!offer) return;
|
|
857
|
+
this.presentedOffers.push({
|
|
858
|
+
...toPresentedOfferConfig(offer),
|
|
859
|
+
guid: offer.decisionId,
|
|
860
|
+
accepted: false,
|
|
861
|
+
presentedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
markCurrentOfferAccepted() {
|
|
865
|
+
const last = this.presentedOffers[this.presentedOffers.length - 1];
|
|
866
|
+
if (!last || last.acceptedAt || last.declinedAt) return;
|
|
867
|
+
last.accepted = true;
|
|
868
|
+
last.acceptedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
869
|
+
}
|
|
870
|
+
markCurrentOfferDeclined() {
|
|
871
|
+
const last = this.presentedOffers[this.presentedOffers.length - 1];
|
|
872
|
+
if (!last || last.acceptedAt || last.declinedAt) return;
|
|
873
|
+
last.declinedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
874
|
+
}
|
|
875
|
+
notify() {
|
|
876
|
+
for (const listener of this.listeners) {
|
|
877
|
+
listener();
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
async executeTokenAction(offer) {
|
|
881
|
+
if (!this.apiClient) return;
|
|
882
|
+
const o = offer;
|
|
883
|
+
switch (o.type) {
|
|
884
|
+
case "discount":
|
|
885
|
+
if (o.couponId) {
|
|
886
|
+
await this.apiClient.applyDiscount(o.couponId, this.blueprintId ?? void 0);
|
|
887
|
+
}
|
|
888
|
+
break;
|
|
889
|
+
case "pause":
|
|
890
|
+
await this.apiClient.pause({ duration: o.months, interval: o.interval ?? "month" });
|
|
891
|
+
break;
|
|
892
|
+
case "plan_change":
|
|
893
|
+
await this.apiClient.changePlan(o.plans[0]?.id);
|
|
894
|
+
break;
|
|
895
|
+
case "trial_extension":
|
|
896
|
+
await this.apiClient.extendTrial(o.days, this.blueprintId ?? void 0);
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
trackStepView(stepType, guid, numChoices) {
|
|
901
|
+
this.stepEnteredAt = Date.now();
|
|
902
|
+
const mapped = toApiStepType(stepType);
|
|
903
|
+
if (!mapped) return;
|
|
904
|
+
const entry = {
|
|
905
|
+
...mapped,
|
|
906
|
+
start: (/* @__PURE__ */ new Date()).toISOString()
|
|
907
|
+
};
|
|
908
|
+
if (guid) entry.guid = guid;
|
|
909
|
+
if (numChoices != null) entry.numChoices = numChoices;
|
|
910
|
+
this.stepsViewed.push(entry);
|
|
911
|
+
}
|
|
912
|
+
finalizeStepView(step) {
|
|
913
|
+
const mapped = toApiStepType(step);
|
|
914
|
+
if (!mapped) return;
|
|
915
|
+
const entry = [...this.stepsViewed].reverse().find((s) => s.stepType === mapped.stepType && s.customStepType === mapped.customStepType);
|
|
916
|
+
if (entry) {
|
|
917
|
+
entry.end = (/* @__PURE__ */ new Date()).toISOString();
|
|
918
|
+
entry.duration = Date.now() - this.stepEnteredAt;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// Offer passed in so accept() can capture it before enterSuccessStep
|
|
922
|
+
// potentially moves currentStepId off the offer step.
|
|
923
|
+
buildAcceptedOffer(offer, result) {
|
|
924
|
+
const { copy: _, ...offerConfig } = offer;
|
|
925
|
+
return {
|
|
926
|
+
...offerConfig,
|
|
927
|
+
reasonId: this.state.selectedReason,
|
|
928
|
+
...result ? { result } : {}
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
// Terminal transition. Prefer moving currentStepId to a declared success
|
|
932
|
+
// step so the developer's savedTitle / classNames / custom component
|
|
933
|
+
// applies; otherwise stay put and the renderer's defaults cover it.
|
|
934
|
+
enterSuccessStep(outcome) {
|
|
935
|
+
const success = this.getStepConfig("success");
|
|
936
|
+
const partial = { step: "success", outcome, isProcessing: false };
|
|
937
|
+
if (success) partial.currentStepId = success.guid;
|
|
938
|
+
this.setState(partial);
|
|
939
|
+
}
|
|
940
|
+
resolveSessionCustomer() {
|
|
941
|
+
const direct = this.directCustomer ? directDataToSessionCustomer(this.directCustomer, this.directSubscriptions ?? void 0) : void 0;
|
|
942
|
+
if (this.creds) {
|
|
943
|
+
return { ...direct, id: this.creds.customerId, subscriptionId: this.creds.subscriptionId };
|
|
944
|
+
}
|
|
945
|
+
return direct;
|
|
946
|
+
}
|
|
947
|
+
buildBasePayload() {
|
|
948
|
+
const selectedReason = this.reasons.find((r) => r.id === this.state.selectedReason);
|
|
949
|
+
const payload = {
|
|
950
|
+
blueprintId: this.blueprintId ?? void 0,
|
|
951
|
+
customer: this.resolveSessionCustomer(),
|
|
952
|
+
canceled: false,
|
|
953
|
+
surveyChoiceId: this.state.selectedReason ?? void 0,
|
|
954
|
+
surveyChoiceValue: selectedReason?.label,
|
|
955
|
+
feedback: this.state.feedback || void 0,
|
|
956
|
+
presentedOffers: this.presentedOffers,
|
|
957
|
+
stepsViewed: this.stepsViewed,
|
|
958
|
+
customStepResults: Object.keys(this.customStepResults).length > 0 ? this.customStepResults : void 0,
|
|
959
|
+
// Token's signed mode wins — the client can't override it.
|
|
960
|
+
mode: (this.creds?.mode ?? this.configMode) === "test" ? "TEST" : "LIVE",
|
|
961
|
+
provider: this.isTokenMode() ? void 0 : "sdk-react",
|
|
962
|
+
embedVersion: "sdk-react"
|
|
963
|
+
};
|
|
964
|
+
if (this.config) {
|
|
965
|
+
const surveyStep = this.config.steps.find((s) => s.type === "survey");
|
|
966
|
+
payload.surveyId = surveyStep?.guid;
|
|
967
|
+
payload.clickToCancelEnabled = this.config.settings.clickToCancelEnabled;
|
|
968
|
+
payload.strictFTCComplianceEnabled = this.config.settings.strictFTCComplianceEnabled;
|
|
969
|
+
payload.usedClickToCancel = false;
|
|
970
|
+
payload.autoOptimizationUsed = !!this.config.autoOptimizationKey;
|
|
971
|
+
payload.autoOptimizationKey = this.config.autoOptimizationKey;
|
|
972
|
+
payload.discountCooldown = this.config.settings.discountCooldown;
|
|
973
|
+
payload.pauseCooldown = this.config.settings.pauseCooldown;
|
|
974
|
+
payload.discountCooldownApplied = false;
|
|
975
|
+
payload.pauseCooldownApplied = false;
|
|
976
|
+
}
|
|
977
|
+
return payload;
|
|
978
|
+
}
|
|
979
|
+
recordAbort() {
|
|
980
|
+
const client = this.apiClient ?? this.analyticsClient;
|
|
981
|
+
if (!client) return;
|
|
982
|
+
this.finalizeStepView(this.state.step);
|
|
983
|
+
client.createSession({ ...this.buildBasePayload(), aborted: true }).catch(() => {
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
// acceptedOffer is a parameter so callers capture it before enterSuccessStep
|
|
987
|
+
// moves currentStepId off the offer step. setState already finalized the
|
|
988
|
+
// step view on the way to 'success', so no extra finalize call here.
|
|
989
|
+
recordOutcome(outcome, acceptedOffer, result) {
|
|
990
|
+
const client = this.apiClient ?? this.analyticsClient;
|
|
991
|
+
if (!client) return;
|
|
992
|
+
const payload = this.buildBasePayload();
|
|
993
|
+
payload.canceled = outcome === "cancelled";
|
|
994
|
+
if (outcome === "saved" && acceptedOffer) {
|
|
995
|
+
payload.acceptedOffer = toAcceptedOfferPayload(acceptedOffer, result);
|
|
996
|
+
}
|
|
997
|
+
client.createSession(payload).catch(() => {
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
function mergeLocalSteps(serverSteps, localSteps) {
|
|
1002
|
+
const localByType = new Map(localSteps.map((s) => [s.type, s]));
|
|
1003
|
+
const merged = serverSteps.map((serverStep) => {
|
|
1004
|
+
const local = localByType.get(serverStep.type);
|
|
1005
|
+
if (!local) return serverStep;
|
|
1006
|
+
localByType.delete(serverStep.type);
|
|
1007
|
+
return { ...serverStep, ...local };
|
|
1008
|
+
});
|
|
1009
|
+
for (const [, step] of localByType) merged.push(step);
|
|
1010
|
+
return merged;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// src/core/token.ts
|
|
1014
|
+
function decodeSessionToken(token) {
|
|
1015
|
+
if (!token.startsWith("ck_")) {
|
|
1016
|
+
throw new Error('Invalid token: must start with "ck_"');
|
|
1017
|
+
}
|
|
1018
|
+
const encoded = token.slice(3);
|
|
1019
|
+
let json;
|
|
1020
|
+
try {
|
|
1021
|
+
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
1022
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
1023
|
+
json = atob(padded);
|
|
1024
|
+
} catch {
|
|
1025
|
+
throw new Error("Invalid token: malformed base64");
|
|
1026
|
+
}
|
|
1027
|
+
let payload;
|
|
1028
|
+
try {
|
|
1029
|
+
payload = JSON.parse(json);
|
|
1030
|
+
} catch {
|
|
1031
|
+
throw new Error("Invalid token: malformed JSON");
|
|
1032
|
+
}
|
|
1033
|
+
if (typeof payload.a !== "string" || !payload.a) {
|
|
1034
|
+
throw new Error("Invalid token: missing appId");
|
|
1035
|
+
}
|
|
1036
|
+
if (typeof payload.c !== "string" || !payload.c) {
|
|
1037
|
+
throw new Error("Invalid token: missing customerId");
|
|
1038
|
+
}
|
|
1039
|
+
if (typeof payload.h !== "string" || !payload.h) {
|
|
1040
|
+
throw new Error("Invalid token: missing authHash");
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
appId: payload.a,
|
|
1044
|
+
customerId: payload.c,
|
|
1045
|
+
subscriptionId: typeof payload.s === "string" ? payload.s : void 0,
|
|
1046
|
+
authHash: payload.h,
|
|
1047
|
+
mode: payload.m === "test" ? "test" : "live",
|
|
1048
|
+
issuedAt: typeof payload.t === "number" ? payload.t : 0
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
export { AnalyticsClient, CancelFlowMachine, ChurnkeyApi, decodeSessionToken };
|
|
1053
|
+
//# sourceMappingURL=chunk-IFVMM2LB.js.map
|
|
1054
|
+
//# sourceMappingURL=chunk-IFVMM2LB.js.map
|