@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 ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Churnkey, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
16
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
19
+ OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,326 @@
1
+ # @churnkey/react
2
+
3
+ A cancel flow component for React. Survey, offers, feedback, and confirmation. Ready to use out of the box, fully customizable when you need it.
4
+
5
+ Open source. Optionally connects to [Churnkey](https://churnkey.co) for analytics and AI-powered retention.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @churnkey/react
11
+ ```
12
+
13
+ ## Create a cancel flow
14
+
15
+ Import the component and the stylesheet, define your steps, and handle the result.
16
+
17
+ ```tsx
18
+ import { CancelFlow } from '@churnkey/react'
19
+ import '@churnkey/react/styles.css'
20
+
21
+ <CancelFlow
22
+ steps={[
23
+ {
24
+ type: 'survey',
25
+ title: 'Why are you leaving?',
26
+ reasons: [
27
+ {
28
+ id: 'expensive',
29
+ label: 'Too expensive',
30
+ offer: {
31
+ type: 'discount',
32
+ couponId: 'STRIPE_SAVE20', // your coupon ID
33
+ percentOff: 20, // for the UI
34
+ durationInMonths: 3, // for the UI
35
+ },
36
+ },
37
+ {
38
+ id: 'not-using',
39
+ label: 'Not using it enough',
40
+ offer: { type: 'pause', months: 2 },
41
+ },
42
+ { id: 'missing', label: 'Missing features' },
43
+ ],
44
+ },
45
+ { type: 'feedback', title: 'Anything else?' },
46
+ { type: 'confirm' },
47
+ ]}
48
+ handleDiscount={async (offer) => myBilling.applyCoupon(offer.couponId)}
49
+ handlePause={async (offer) => myBilling.pause({ months: offer.months })}
50
+ handleCancel={async () => myBilling.cancel()}
51
+ onClose={() => setOpen(false)}
52
+ />
53
+ ```
54
+
55
+ When a customer selects "Too expensive," the SDK shows the discount offer. If they accept, `handleDiscount` runs. If they decline all offers and confirm, `handleCancel` runs.
56
+
57
+ ## Change the look
58
+
59
+ The SDK ships light and dark schemes. `'auto'` follows the user's OS preference and reacts to changes.
60
+
61
+ ```tsx
62
+ <CancelFlow
63
+ appearance={{ colorScheme: 'auto' }}
64
+ steps={steps}
65
+ onAccept={handleOffer}
66
+ onCancel={handleCancel}
67
+ />
68
+ ```
69
+
70
+ Override individual design tokens via `appearance.variables`. The same value applies in both schemes — set your brand color once and it shows up everywhere.
71
+
72
+ ```tsx
73
+ appearance={{
74
+ variables: { colorPrimary: '#7c3aed', borderRadius: '12px' },
75
+ }}
76
+ ```
77
+
78
+ For class-based styling (Tailwind, CSS modules), use `classNames`:
79
+
80
+ ```tsx
81
+ classNames={{
82
+ modal: 'max-w-lg shadow-2xl',
83
+ overlay: 'bg-black/60 backdrop-blur-sm',
84
+ }}
85
+ ```
86
+
87
+ ## Replace components
88
+
89
+ Swap out any piece of the UI. The SDK handles navigation and state; you handle rendering.
90
+
91
+ ```tsx
92
+ <CancelFlow
93
+ steps={steps}
94
+ components={{
95
+ ReasonButton: ({ reason, isSelected, onSelect }) => (
96
+ <button
97
+ onClick={() => onSelect(reason.id)}
98
+ className={isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}
99
+ >
100
+ {reason.label}
101
+ </button>
102
+ ),
103
+ }}
104
+ onAccept={handleOffer}
105
+ onCancel={handleCancel}
106
+ />
107
+ ```
108
+
109
+ You can replace `Modal`, `CloseButton`, `BackButton`, the step components (`Survey`, `Offer`, `Feedback`, `Confirm`, `Success`), `ReasonButton`, and per-offer-type components (`DiscountOffer`, `PauseOffer`, `PlanChangeOffer`, `TrialExtensionOffer`, `ContactOffer`, `RedirectOffer`).
110
+
111
+ ## Add custom steps
112
+
113
+ The step system is open. Use any string as a step type, then register a component for it.
114
+
115
+ ```tsx
116
+ <CancelFlow
117
+ steps={[
118
+ { type: 'survey', reasons: [
119
+ { id: 'seats', label: 'Too many seats',
120
+ offer: { type: 'change-seats', data: { minSeats: 1 } } },
121
+ ]},
122
+ { type: 'nps', title: 'One quick question', data: { scale: 10 } },
123
+ { type: 'feedback' },
124
+ { type: 'confirm' },
125
+ ]}
126
+ customComponents={{
127
+ 'nps': ({ step, onNext }) => (
128
+ <NpsRating scale={step.data.scale} onSubmit={(score) => onNext({ score })} />
129
+ ),
130
+ 'change-seats': ({ offer, onAccept }) => (
131
+ <SeatAdjuster onConfirm={(seats) => onAccept({ seats })} />
132
+ ),
133
+ }}
134
+ onAccept={handleOffer}
135
+ onCancel={handleCancel}
136
+ />
137
+ ```
138
+
139
+ Custom steps navigate like built-in ones. Custom offers appear when a matching reason is selected. Whatever you pass to `onNext(result)` or `onAccept(result)` shows up on `offer.result` in your `onAccept` handler, and is recorded with the session for analytics (as `customStepResults[stepType]` for steps, `acceptedOffer.customOfferResult` for offers).
140
+
141
+ ## Go headless
142
+
143
+ Use the hook directly if you want full control over the UI.
144
+
145
+ ```tsx
146
+ import { useCancelFlow } from '@churnkey/react/headless'
147
+
148
+ function MyCancelPage() {
149
+ const flow = useCancelFlow({ steps, onAccept: handleOffer, onCancel: handleCancel })
150
+
151
+ if (flow.step === 'survey') {
152
+ return (
153
+ <div>
154
+ {flow.reasons.map((r) => (
155
+ <button key={r.id} onClick={() => flow.selectReason(r.id)}>{r.label}</button>
156
+ ))}
157
+ <button onClick={flow.next} disabled={!flow.selectedReason}>Continue</button>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ if (flow.step === 'offer' && flow.currentOffer) {
163
+ return (
164
+ <div>
165
+ <h2>{flow.currentOffer.copy.headline}</h2>
166
+ <button onClick={flow.accept}>Accept</button>
167
+ <button onClick={flow.decline}>No thanks</button>
168
+ </div>
169
+ )
170
+ }
171
+
172
+ // ... feedback, confirm, success
173
+ }
174
+ ```
175
+
176
+ The hook returns the current state (`step`, `reasons`, `currentOffer`, `feedback`, `outcome`, `isProcessing`) and actions (`selectReason`, `next`, `back`, `accept`, `decline`, `cancel`, `close`).
177
+
178
+ ## Add analytics
179
+
180
+ Create a free account at [churnkey.co](https://churnkey.co) and pass your app ID. The SDK records each session so you can see why customers cancel, which offers work, and what your save rate looks like.
181
+
182
+ ```tsx
183
+ <CancelFlow
184
+ appId="app_xxx"
185
+ customer={{ id: 'cus_123' }}
186
+ steps={steps}
187
+ onAccept={handleOffer}
188
+ onCancel={handleCancel}
189
+ />
190
+ ```
191
+
192
+ Only `customer.id` is required. For revenue metrics, pass subscription data too:
193
+
194
+ ```tsx
195
+ <CancelFlow
196
+ appId="app_xxx"
197
+ customer={{ id: 'cus_123', email: 'jane@acme.com' }}
198
+ subscriptions={[{
199
+ id: 'sub_456',
200
+ start: '2024-06-01',
201
+ status: { name: 'active', currentPeriod: { start: '2025-04-01', end: '2025-05-01' } },
202
+ items: [{ price: { id: 'price_pro', amount: { value: 2999 } } }],
203
+ }]}
204
+ steps={steps}
205
+ onAccept={handleOffer}
206
+ onCancel={handleCancel}
207
+ />
208
+ ```
209
+
210
+ Your steps and callbacks don't change. No backend work required.
211
+
212
+ Sessions are recorded when the customer accepts an offer, confirms cancellation, or closes the modal before completing (abandoned). Data from custom steps passed via `onNext(result)` is captured alongside the session.
213
+
214
+ To keep staging traffic out of your production analytics, pass `mode="test"`:
215
+
216
+ ```tsx
217
+ <CancelFlow
218
+ appId="app_xxx"
219
+ customer={{ id: 'cus_123' }}
220
+ mode={process.env.NODE_ENV === 'production' ? 'live' : 'test'}
221
+ steps={steps}
222
+ onAccept={handleOffer}
223
+ onCancel={handleCancel}
224
+ />
225
+ ```
226
+
227
+ Defaults to `'live'`. In token mode, the token's mode takes precedence — it's server-signed and can't be overridden client-side.
228
+
229
+ ## Let Churnkey handle billing
230
+
231
+ Connect your billing provider (Stripe, Chargebee, etc.) in the Churnkey dashboard and Churnkey can apply discounts, pause subscriptions, and cancel on your behalf. Generate a token on your server to authenticate the session.
232
+
233
+ ```typescript
234
+ // Server
235
+ import { Churnkey } from '@churnkey/node'
236
+
237
+ const ck = new Churnkey({ appId: 'app_xxx', apiKey: 'sk_xxx' })
238
+ const token = ck.createToken({ customerId: 'cus_123' })
239
+ ```
240
+
241
+ ```tsx
242
+ // Client
243
+ <CancelFlow
244
+ appId="app_xxx"
245
+ customer={{ id: 'cus_123', email: 'jane@acme.com' }}
246
+ subscriptions={[...]}
247
+ session={token}
248
+ onAccept={async (offer) => console.log('Applied:', offer)}
249
+ onCancel={async () => router.push('/goodbye')}
250
+ />
251
+ ```
252
+
253
+ In this mode, the cancel flow is configured from the Churnkey dashboard. Your custom components and appearance settings carry over.
254
+
255
+ ### Handlers vs. listeners
256
+
257
+ Two kinds of callbacks, distinguished by name:
258
+
259
+ - **`handle<Type>`** — runs the action. Replaces what Churnkey would do on the server. In local mode (no token) this is the only thing that runs the action.
260
+ - **`on<Type>`** — fires after the action, regardless of who ran it. Side effects only — analytics, refetch, toasts.
261
+
262
+ ```tsx
263
+ <CancelFlow
264
+ session={token}
265
+ // Handler: defining handlePause tells the SDK NOT to call its own
266
+ // server-side pause; you run the action instead. Skip handle* and
267
+ // Churnkey takes the action automatically in token mode.
268
+ handlePause={async (offer, customer) => {
269
+ await myBilling.pause({ months: offer.months })
270
+ }}
271
+ // Listener: fires after the pause completes (whoever ran it).
272
+ onPause={(offer, customer) => analytics.track('paused', { months: offer.months })}
273
+ onAccept={(offer) => analytics.track('offer_accepted', { type: offer.type })}
274
+ onCancel={() => router.push('/goodbye')}
275
+ />
276
+ ```
277
+
278
+ Available handlers: `handleDiscount`, `handlePause`, `handlePlanChange`, `handleTrialExtension`, `handleCancel`. Available listeners: `onDiscount`, `onPause`, `onPlanChange`, `onTrialExtension`, `onCancel`, plus the catch-all `onAccept` that fires for any accepted offer.
279
+
280
+ In **local mode** (no token), there's no server action — handlers do the work. In **token mode**, defining a handler opts out of Churnkey running the action and gives the work back to you.
281
+
282
+ ### Passing customer/subscription data alongside a token
283
+
284
+ You can pass `customer` and `subscriptions` together with `session` to enrich the session record with client-side data Churnkey doesn't already have (custom metadata, current plan price, etc.). The token's signed customer ID stays authoritative; direct data fills in the gaps.
285
+
286
+ ```tsx
287
+ <CancelFlow
288
+ appId="app_xxx"
289
+ customer={{ id: 'cus_123', email: 'jane@acme.com', metadata: { plan: 'pro' } }}
290
+ subscriptions={[...]}
291
+ session={token}
292
+ onAccept={handleOffer}
293
+ onCancel={handleCancel}
294
+ />
295
+ ```
296
+
297
+ ### Mixing local and server steps
298
+
299
+ You can pass both `session` and `steps` to override specific server config:
300
+
301
+ ```tsx
302
+ <CancelFlow
303
+ session={token}
304
+ steps={[
305
+ { type: 'confirm', title: 'We hate to see you go' },
306
+ { type: 'nps', title: 'Quick question', data: { scale: 10 } },
307
+ ]}
308
+ customComponents={{ 'nps': NpsStep }}
309
+ onAccept={handleOffer}
310
+ onCancel={handleCancel}
311
+ />
312
+ ```
313
+
314
+ Local steps override by type. Steps not in the server config are appended.
315
+
316
+ ## Imports
317
+
318
+ ```tsx
319
+ import { CancelFlow } from '@churnkey/react' // drop-in component
320
+ import { useCancelFlow } from '@churnkey/react/headless' // headless hook
321
+ import { CancelFlowMachine } from '@churnkey/react/core' // state machine, no React
322
+ ```
323
+
324
+ ## License
325
+
326
+ MIT