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