Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 111 additions & 1 deletion apps/playground/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { CancelFlow } from '@churnkey/react'
import type { CustomOfferProps, CustomStepProps, ReasonButtonProps, Step } from '@churnkey/react/core'
import type {
CustomOfferProps,
CustomStepProps,
DirectSubscription,
ReasonButtonProps,
Step,
} from '@churnkey/react/core'
import { useCancelFlow } from '@churnkey/react/headless'
import { PassthroughOffer, TermExtensionOffer } from '@churnkey/react/recipes'
import type { CSSProperties } from 'react'
import { useState } from 'react'
import '@churnkey/react/styles.css'
Expand Down Expand Up @@ -439,6 +446,100 @@ function CustomStepDemo() {
)
}

// --- 5b. Prebuilt recipes (@churnkey/react/recipes) ---

const recipeSubscriptions: DirectSubscription[] = [
{
id: 'sub_demo',
start: '2026-01-01T00:00:00Z',
status: {
name: 'active',
currentPeriod: { start: '2026-07-01T00:00:00Z', end: '2026-08-01T00:00:00Z' },
},
items: [],
},
]

const recipeDemoSteps: Step[] = [
{
type: 'survey',
title: 'Why are you cancelling?',
reasons: [
{
id: 'term',
label: 'Billing timing (→ term extension)',
offer: {
type: 'annual_term_extension',
data: { days: 30 },
copy: {
headline: 'Push your next charge back a month',
body: 'Keep full access — your renewal just moves 30 days out.',
cta: 'Extend my term',
declineCta: 'No thanks',
},
},
},
{
id: 'scheduled',
label: 'Wrong plan (→ scheduled transfer)',
offer: {
type: 'scheduled_transfer',
copy: {
headline: 'Switch plans at renewal',
body: 'We will move you to the annual plan when this period ends.',
cta: 'Schedule the switch',
declineCta: 'No thanks',
},
},
},
{
id: 'immediate',
label: 'Switch now (→ immediate transfer)',
offer: {
type: 'immediate_transfer',
copy: {
headline: 'Switch plans today',
body: 'We will move you to the annual plan right away, prorated.',
cta: 'Switch now',
declineCta: 'No thanks',
},
},
},
],
},
{ type: 'confirm' },
]

function RecipesDemo() {
const [open, setOpen] = useState(false)
return (
<section>
<h2 style={{ marginBottom: 4 }}>5b. Prebuilt Recipes</h2>
<p style={descStyle}>
<code>TermExtensionOffer</code> and <code>PassthroughOffer</code> imported from{' '}
<code>@churnkey/react/recipes</code> — accepted payloads land in the console.
</p>
<button type="button" onClick={() => setOpen(true)} style={btnStyle}>
Open
</button>
{open && (
<CancelFlow
steps={recipeDemoSteps}
subscriptions={recipeSubscriptions}
customComponents={{
annual_term_extension: TermExtensionOffer,
scheduled_transfer: PassthroughOffer,
immediate_transfer: PassthroughOffer,
}}
onAccept={handleAccept}
onCancel={handleCancel}
onClose={() => setOpen(false)}
/>
)}
</section>
)
}

// --- 6. Headless ---

function HeadlessDemo() {
Expand Down Expand Up @@ -557,6 +658,14 @@ function TokenModeDemo() {
<CancelFlow
session={activeToken}
apiBaseUrl={apiBaseUrl}
// Attribute layer for server-side segment matching — the API picks
// the blueprint whose segment filter matches these values.
customerAttributes={{ plan_tier: 'legacy' }}
customComponents={{
annual_term_extension: TermExtensionOffer,
scheduled_transfer: PassthroughOffer,
immediate_transfer: PassthroughOffer,
}}
onAccept={async (offer) => console.log('Accepted:', offer)}
onCancel={async () => console.log('Cancelled')}
onClose={() => setOpen(false)}
Expand Down Expand Up @@ -584,6 +693,7 @@ export function App() {
<AllOfferTypesDemo />
<ComponentOverrideDemo />
<CustomStepDemo />
<RecipesDemo />
<HeadlessDemo />
<TokenModeDemo />
</div>
Expand Down
20 changes: 10 additions & 10 deletions apps/playground/src/RecipeBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import type { CSSProperties, ReactElement } from 'react'
import { useState } from 'react'
import '@churnkey/react/styles.css'

import { ConfirmWithImage } from '../../../packages/react/recipes/confirm-with-image'
import confirmWithImageSrc from '../../../packages/react/recipes/confirm-with-image.tsx?raw'
import { ContactWithSupportCard } from '../../../packages/react/recipes/contact-with-support-card'
import contactWithSupportCardSrc from '../../../packages/react/recipes/contact-with-support-card.tsx?raw'
import { NpsWithFaces } from '../../../packages/react/recipes/nps-with-faces'
import npsWithFacesSrc from '../../../packages/react/recipes/nps-with-faces.tsx?raw'
import { PlanChangeStackedRows } from '../../../packages/react/recipes/plan-change-stacked-rows'
import planChangeStackedRowsSrc from '../../../packages/react/recipes/plan-change-stacked-rows.tsx?raw'
import { SeatChangeBuckets } from '../../../packages/react/recipes/seat-change-buckets'
import seatChangeBucketsSrc from '../../../packages/react/recipes/seat-change-buckets.tsx?raw'
import { ConfirmWithImage } from '../../../packages/react/examples/confirm-with-image'
import confirmWithImageSrc from '../../../packages/react/examples/confirm-with-image.tsx?raw'
import { ContactWithSupportCard } from '../../../packages/react/examples/contact-with-support-card'
import contactWithSupportCardSrc from '../../../packages/react/examples/contact-with-support-card.tsx?raw'
import { NpsWithFaces } from '../../../packages/react/examples/nps-with-faces'
import npsWithFacesSrc from '../../../packages/react/examples/nps-with-faces.tsx?raw'
import { PlanChangeStackedRows } from '../../../packages/react/examples/plan-change-stacked-rows'
import planChangeStackedRowsSrc from '../../../packages/react/examples/plan-change-stacked-rows.tsx?raw'
import { SeatChangeBuckets } from '../../../packages/react/examples/seat-change-buckets'
import seatChangeBucketsSrc from '../../../packages/react/examples/seat-change-buckets.tsx?raw'

interface Recipe {
id: string
Expand Down
2 changes: 1 addition & 1 deletion apps/playground/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "../../packages/react/recipes"]
"include": ["src", "../../packages/react/examples"]
}
1 change: 1 addition & 0 deletions apps/playground/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default defineConfig({
// Vite handles the TypeScript natively.
'@churnkey/react/headless': path.resolve(reactSrc, 'headless/index.ts'),
'@churnkey/react/core': path.resolve(reactSrc, 'core/index.ts'),
'@churnkey/react/recipes': path.resolve(reactSrc, 'recipes/index.ts'),
'@churnkey/react/styles.css': path.resolve(reactSrc, 'styles/cancel-flow.css'),
'@churnkey/react': path.resolve(reactSrc, 'index.ts'),
},
Expand Down
26 changes: 26 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,31 @@ The step system is open. Use any string as a step type, then register a componen

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).

## Prebuilt custom-offer components

If you'd rather not build a custom offer's UI yourself, import a prebuilt one from `@churnkey/react/recipes`. They render with the same styling system as the built-in offers and pass their result through `onAccept`, so you only implement the billing side.

```tsx
import { TermExtensionOffer, PassthroughOffer } from '@churnkey/react/recipes'

<CancelFlow
customComponents={{
// "Delay your next charge by N days". Reads offer.data.days, shows the
// new renewal date, passes { days } through onAccept.
annual_term_extension: TermExtensionOffer,
// Copy + accept/decline, nothing else. Register it for any offer type
// whose substance lives entirely in the flow-authored copy; offer.data
// passes through as the result.
scheduled_transfer: PassthroughOffer,
immediate_transfer: PassthroughOffer,
}}
onAccept={handleOffer}
onCancel={handleCancel}
/>
```

The components live behind a subpath export, so they add nothing to your bundle unless imported. For full presentation control, copy a template from `packages/react/examples/` into your codebase instead and own it from there.

## Go headless

Use the hook directly if you want full control over the UI.
Expand Down Expand Up @@ -319,6 +344,7 @@ Local steps override by type. Steps not in the server config are appended.
import { CancelFlow } from '@churnkey/react' // drop-in component
import { useCancelFlow } from '@churnkey/react/headless' // headless hook
import { CancelFlowMachine } from '@churnkey/react/core' // state machine, no React
import { TermExtensionOffer } from '@churnkey/react/recipes' // prebuilt custom-offer components
```

## License
Expand Down
10 changes: 10 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@
"default": "./dist/core.cjs"
}
},
"./recipes": {
"import": {
"types": "./dist/recipes.d.ts",
"default": "./dist/recipes.js"
},
"require": {
"types": "./dist/recipes.d.cts",
"default": "./dist/recipes.cjs"
}
},
"./styles.css": {
"types": "./dist/styles.css.d.ts",
"default": "./dist/styles.css"
Expand Down
7 changes: 5 additions & 2 deletions packages/react/src/core/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,13 @@ export class CancelFlowMachine {
}
if (config.mode) this.configMode = config.mode
if (config.customerAttributes) this.customerAttributes = config.customerAttributes
// Customer/subscription data seeds component props and merge fields in
// local modes, and enriches the session payload in token mode (see
// resolveSessionCustomer); analytics additionally needs an appId.
this.directCustomer = config.customer ?? null
this.directSubscriptions = config.subscriptions ?? null
if (config.appId && config.customer) {
this.analyticsClient = new AnalyticsClient(config.appId, config.apiBaseUrl)
this.directCustomer = config.customer
this.directSubscriptions = config.subscriptions ?? null
}

// Token mode defers graph construction until initializeFromConfig runs
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ function transformReason(r: SdkReason): ReasonConfig {
label: r.label,
}
if (r.freeform) out.freeform = true
if (r.offer) out.offer = transformOfferConfig(r.offer)
// Keep the full decision — server-resolved copy and decisionId — so
// survey-attached offers render dashboard copy instead of the synthesized
// defaults, and presented/accepted analytics join on decisionId.
if (r.offer) out.offer = transformOfferDecision(r.offer)
return out
}

Expand Down
7 changes: 6 additions & 1 deletion packages/react/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,12 @@ export interface ReasonConfig {
* static `label`, so analytics groupings stay stable.
*/
freeform?: boolean
offer?: OfferConfig
/**
* Offer revealed when this reason is picked. `copy` is optional — the SDK
* synthesizes default copy from the offer config when none is provided,
* same as standalone offer steps.
*/
offer?: OfferConfig | OfferDecision
}

// ─── Steps ───────────────────────────────────────────────────────────────────
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/recipes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Prebuilt custom-offer components, maintained and themed like the built-in
// defaults. Import from '@churnkey/react/recipes' and register under the
// custom offer types configured in the flow builder via `customComponents`.
export { PassthroughOffer } from './passthrough-offer'
export { TermExtensionOffer } from './term-extension-offer'
48 changes: 48 additions & 0 deletions packages/react/src/recipes/passthrough-offer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { RichText } from '../components/rich-text'
import { defaultMessages } from '../core/messages'
import type { CustomOfferProps } from '../core/types'

/**
* Prebuilt renderer for custom offers whose substance lives entirely in the
* builder copy — the customer reads the pitch and accepts or declines,
* nothing else. One component covers any number of such offer types; the
* per-type messaging comes from the server-resolved `offer.copy`:
*
* <CancelFlow
* customComponents={{
* scheduled_transfer: PassthroughOffer,
* immediate_transfer: PassthroughOffer,
* }}
* onAccept={handleAccept}
* />
*
* On accept, `offer.data` (the builder-configured field values, if any)
* passes through as the result; the merchant's handler tells offer types
* apart via `customOfferType` on the accepted payload.
*/
export function PassthroughOffer({ offer, onAccept, onDecline, isProcessing }: CustomOfferProps) {
const msg = defaultMessages
// OfferDecision is a union; only the custom member carries `data`.
const data = (offer as { data?: Record<string, unknown> }).data

return (
<div className="ck-step ck-step-offer">
{offer.copy.headline && <h2 className="ck-step-title">{offer.copy.headline}</h2>}
{offer.copy.body && <RichText as="div" html={offer.copy.body} className="ck-step-description" />}

<div className="ck-offer-card">
<button
type="button"
className="ck-button ck-button-primary"
onClick={() => onAccept(data)}
disabled={isProcessing}
>
{isProcessing ? msg.common.processing : offer.copy.cta}
</button>
<button type="button" className="ck-button-link" onClick={onDecline}>
{msg.offer.declineCta || offer.copy.declineCta}
</button>
</div>
</div>
)
}
Loading
Loading