@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
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
|