From 766ef8ce10dbfb4d0fbdd1e08c5b08f8be004a49 Mon Sep 17 00:00:00 2001 From: Pavel Date: Tue, 21 Jul 2026 22:03:11 +0300 Subject: [PATCH 1/3] Add prebuilt custom-offer recipes to @churnkey/react Ship TermExtensionOffer and PassthroughOffer behind a new @churnkey/react/recipes subpath export so merchants can render merchant-executed custom offers (term extension, transfers) without building the UI themselves. Recipes reuse the built-in offer styling and pass their result through onAccept. Also fixes two SDK bugs surfaced while testing the recipes end to end: - machine: customer/subscriptions props were dropped in local mode unless appId was set, so components never saw the period end - transform: survey-attached offers lost server-resolved copy and decisionId in token mode, rendering default copy instead of the dashboard's and breaking the presented/accepted analytics join ReasonConfig.offer now accepts OfferDecision to match the runtime, the copy-paste templates moved from recipes/ to examples/ to free the name, and the playground gained a prebuilt-recipes demo section plus recipe wiring in token mode. --- apps/playground/src/App.tsx | 110 +++++++++++++++- apps/playground/src/RecipeBrowser.tsx | 20 +-- apps/playground/tsconfig.json | 2 +- apps/playground/vite.config.ts | 1 + packages/react/README.md | 26 ++++ .../confirm-with-image.tsx | 0 .../contact-with-support-card.tsx | 0 .../{recipes => examples}/nps-with-faces.tsx | 0 .../plan-change-stacked-rows.tsx | 0 .../seat-change-buckets.tsx | 0 packages/react/package.json | 10 ++ packages/react/src/core/machine.ts | 6 +- packages/react/src/core/transform.ts | 5 +- packages/react/src/core/types.ts | 7 +- packages/react/src/recipes/index.ts | 5 + .../react/src/recipes/passthrough-offer.tsx | 48 +++++++ .../src/recipes/term-extension-offer.tsx | 83 ++++++++++++ packages/react/tests/core/transform.test.ts | 36 ++++++ packages/react/tests/recipes/recipes.test.tsx | 122 ++++++++++++++++++ packages/react/tsup.config.ts | 1 + 20 files changed, 466 insertions(+), 16 deletions(-) rename packages/react/{recipes => examples}/confirm-with-image.tsx (100%) rename packages/react/{recipes => examples}/contact-with-support-card.tsx (100%) rename packages/react/{recipes => examples}/nps-with-faces.tsx (100%) rename packages/react/{recipes => examples}/plan-change-stacked-rows.tsx (100%) rename packages/react/{recipes => examples}/seat-change-buckets.tsx (100%) create mode 100644 packages/react/src/recipes/index.ts create mode 100644 packages/react/src/recipes/passthrough-offer.tsx create mode 100644 packages/react/src/recipes/term-extension-offer.tsx create mode 100644 packages/react/tests/recipes/recipes.test.tsx diff --git a/apps/playground/src/App.tsx b/apps/playground/src/App.tsx index 7d38957..c343375 100644 --- a/apps/playground/src/App.tsx +++ b/apps/playground/src/App.tsx @@ -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' @@ -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 ( +
+

5b. Prebuilt Recipes

+

+ TermExtensionOffer and PassthroughOffer imported from{' '} + @churnkey/react/recipes — accepted payloads land in the console. +

+ + {open && ( + setOpen(false)} + /> + )} +
+ ) +} + // --- 6. Headless --- function HeadlessDemo() { @@ -557,6 +658,12 @@ function TokenModeDemo() { console.log('Accepted:', offer)} onCancel={async () => console.log('Cancelled')} onClose={() => setOpen(false)} @@ -584,6 +691,7 @@ export function App() { + diff --git a/apps/playground/src/RecipeBrowser.tsx b/apps/playground/src/RecipeBrowser.tsx index b5d6dfe..d7089fd 100644 --- a/apps/playground/src/RecipeBrowser.tsx +++ b/apps/playground/src/RecipeBrowser.tsx @@ -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 diff --git a/apps/playground/tsconfig.json b/apps/playground/tsconfig.json index b2c2521..58b237a 100644 --- a/apps/playground/tsconfig.json +++ b/apps/playground/tsconfig.json @@ -5,5 +5,5 @@ "noEmit": true, "types": ["vite/client"] }, - "include": ["src", "../../packages/react/recipes"] + "include": ["src", "../../packages/react/examples"] } diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts index 8835935..926ae32 100644 --- a/apps/playground/vite.config.ts +++ b/apps/playground/vite.config.ts @@ -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'), }, diff --git a/packages/react/README.md b/packages/react/README.md index 1da67f8..a889bb3 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -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' + + +``` + +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. @@ -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 diff --git a/packages/react/recipes/confirm-with-image.tsx b/packages/react/examples/confirm-with-image.tsx similarity index 100% rename from packages/react/recipes/confirm-with-image.tsx rename to packages/react/examples/confirm-with-image.tsx diff --git a/packages/react/recipes/contact-with-support-card.tsx b/packages/react/examples/contact-with-support-card.tsx similarity index 100% rename from packages/react/recipes/contact-with-support-card.tsx rename to packages/react/examples/contact-with-support-card.tsx diff --git a/packages/react/recipes/nps-with-faces.tsx b/packages/react/examples/nps-with-faces.tsx similarity index 100% rename from packages/react/recipes/nps-with-faces.tsx rename to packages/react/examples/nps-with-faces.tsx diff --git a/packages/react/recipes/plan-change-stacked-rows.tsx b/packages/react/examples/plan-change-stacked-rows.tsx similarity index 100% rename from packages/react/recipes/plan-change-stacked-rows.tsx rename to packages/react/examples/plan-change-stacked-rows.tsx diff --git a/packages/react/recipes/seat-change-buckets.tsx b/packages/react/examples/seat-change-buckets.tsx similarity index 100% rename from packages/react/recipes/seat-change-buckets.tsx rename to packages/react/examples/seat-change-buckets.tsx diff --git a/packages/react/package.json b/packages/react/package.json index 09fbd71..e428dc0 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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" diff --git a/packages/react/src/core/machine.ts b/packages/react/src/core/machine.ts index db67690..6c32262 100644 --- a/packages/react/src/core/machine.ts +++ b/packages/react/src/core/machine.ts @@ -248,10 +248,12 @@ 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 + // every local mode; 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 diff --git a/packages/react/src/core/transform.ts b/packages/react/src/core/transform.ts index c7ea517..776d18e 100644 --- a/packages/react/src/core/transform.ts +++ b/packages/react/src/core/transform.ts @@ -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 } diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index d4dd0bc..dc0a20f 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -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 ─────────────────────────────────────────────────────────────────── diff --git a/packages/react/src/recipes/index.ts b/packages/react/src/recipes/index.ts new file mode 100644 index 0000000..3c076d4 --- /dev/null +++ b/packages/react/src/recipes/index.ts @@ -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' diff --git a/packages/react/src/recipes/passthrough-offer.tsx b/packages/react/src/recipes/passthrough-offer.tsx new file mode 100644 index 0000000..55a4ffb --- /dev/null +++ b/packages/react/src/recipes/passthrough-offer.tsx @@ -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`: + * + * + * + * 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 }).data + + return ( +
+ {offer.copy.headline &&

{offer.copy.headline}

} + {offer.copy.body && } + +
+ + +
+
+ ) +} diff --git a/packages/react/src/recipes/term-extension-offer.tsx b/packages/react/src/recipes/term-extension-offer.tsx new file mode 100644 index 0000000..1be7e36 --- /dev/null +++ b/packages/react/src/recipes/term-extension-offer.tsx @@ -0,0 +1,83 @@ +import { RichText } from '../components/rich-text' +import { formatMonthDay } from '../core/format' +import { defaultMessages } from '../core/messages' +import type { CustomOfferProps, DirectSubscription } from '../core/types' + +/** + * Prebuilt renderer for merchant-executed term extension offers ("delay + * your next charge by N days"). Register it under the custom offer type + * configured in the flow builder: + * + * + * + * Reads `offer.data.days` (a flow-builder field), shows the extension and + * the resulting renewal date, and passes `{ days }` through `onAccept` so + * the merchant's billing handler doesn't need to re-derive it. + */ +export function TermExtensionOffer({ offer, subscriptions, onAccept, onDecline, isProcessing }: CustomOfferProps) { + const msg = defaultMessages + // OfferDecision is a union; only the custom member carries `data`. + const days = readDays((offer as { data?: Record }).data) + const newEnd = extendedPeriodEnd(subscriptions, days) + + return ( +
+ {offer.copy.headline &&

{offer.copy.headline}

} + {offer.copy.body && } + +
+ {days != null && ( + // Same badge layout the built-in trial extension uses, so the + // recipe inherits its styling wholesale. +
+
+
+{days}
+
{days === 1 ? msg.common.day : msg.common.days}
+
+ {newEnd && ( +
+
{msg.offer.newEndDateLabel}
+
{formatMonthDay(newEnd)}
+
+ )} +
+ )} + + +
+
+ ) +} + +function readDays(data: Record | undefined): number | null { + const raw = data?.days + const days = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN + return Number.isFinite(days) && days > 0 ? Math.round(days) : null +} + +// Term extension delays the next charge, so the new date is the current +// period end plus the extension — unlike trial extension, which counts +// from today. Null when the period end isn't known (e.g. local mode +// without subscriptions), in which case the date row is simply omitted. +function extendedPeriodEnd(subscriptions: DirectSubscription[], days: number | null): Date | null { + if (days == null) return null + const status = subscriptions[0]?.status + if (!status || !('currentPeriod' in status) || !status.currentPeriod?.end) return null + const end = status.currentPeriod.end + const base = end instanceof Date ? new Date(end.getTime()) : new Date(end) + if (Number.isNaN(base.getTime())) return null + base.setDate(base.getDate() + days) + return base +} diff --git a/packages/react/tests/core/transform.test.ts b/packages/react/tests/core/transform.test.ts index a20dd3c..b039eab 100644 --- a/packages/react/tests/core/transform.test.ts +++ b/packages/react/tests/core/transform.test.ts @@ -56,6 +56,42 @@ describe('transformSdkConfig', () => { expect(steps[2].type).toBe('confirm') }) + it('keeps server copy and decisionId on survey-attached offers', () => { + const config = { + blueprintId: 'bp_1', + steps: [ + { + type: 'survey', + guid: 's1', + reasons: [ + { + id: 'r1', + label: 'Billing timing', + offer: { + type: 'annual_term_extension', + data: { days: 23 }, + decisionId: 'dec_1', + copy: { headline: 'Extend your term!', body: 'Take it', cta: 'Accept', declineCta: 'No thanks' }, + }, + }, + ], + }, + ], + customer: { id: 'cus_1' }, + subscriptions: [], + settings: { clickToCancelEnabled: false, strictFTCComplianceEnabled: false }, + } as unknown as SdkConfig + + const { steps } = transformSdkConfig(config) + const reason = (steps[0] as { reasons: { offer?: Record }[] }).reasons[0] + expect(reason.offer).toMatchObject({ + type: 'annual_term_extension', + data: { days: 23 }, + decisionId: 'dec_1', + copy: { headline: 'Extend your term!' }, + }) + }) + it('preserves resolved discount fields on the step-attached offer', () => { const config: SdkConfig = { blueprintId: 'bp_1', diff --git a/packages/react/tests/recipes/recipes.test.tsx b/packages/react/tests/recipes/recipes.test.tsx new file mode 100644 index 0000000..ea2112a --- /dev/null +++ b/packages/react/tests/recipes/recipes.test.tsx @@ -0,0 +1,122 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { defaultMessages } from '../../src/core/messages' +import type { CustomOfferProps, DirectSubscription, OfferDecision } from '../../src/core/types' +import { PassthroughOffer } from '../../src/recipes/passthrough-offer' +import { TermExtensionOffer } from '../../src/recipes/term-extension-offer' + +const copy = { + headline: 'Stay a little longer', + body: 'We will push your next charge out.', + cta: 'Extend my term', + declineCta: 'No thanks', +} + +const activeSubscription: DirectSubscription = { + id: 'sub_1', + start: '2026-01-01T12:00:00Z', + status: { + name: 'active', + currentPeriod: { start: '2026-07-01T12:00:00Z', end: '2026-08-01T12:00:00Z' }, + }, + items: [], +} + +function makeProps(overrides: Partial = {}): CustomOfferProps { + return { + offer: { type: 'annual_term_extension', data: { days: 30 }, copy } as OfferDecision, + customer: null, + subscriptions: [activeSubscription], + onAccept: vi.fn().mockResolvedValue(undefined), + onDecline: vi.fn(), + isProcessing: false, + ...overrides, + } +} + +describe('TermExtensionOffer', () => { + it('renders copy, the extension badge, and the new period end date', () => { + render() + expect(screen.getByText('Stay a little longer')).toBeInTheDocument() + expect(screen.getByText('We will push your next charge out.')).toBeInTheDocument() + expect(screen.getByText('+30')).toBeInTheDocument() + // Aug 1 period end + 30 days. + expect(screen.getByText('Aug 31')).toBeInTheDocument() + }) + + it('omits the date when no subscription period end is available', () => { + render() + expect(screen.getByText('+30')).toBeInTheDocument() + expect(screen.queryByText(defaultMessages.offer.newEndDateLabel)).not.toBeInTheDocument() + }) + + it('passes { days } through onAccept', async () => { + const user = userEvent.setup() + const props = makeProps() + render() + await user.click(screen.getByText('Extend my term')) + expect(props.onAccept).toHaveBeenCalledWith({ days: 30 }) + }) + + it('still renders and accepts when days is missing from offer data', async () => { + const user = userEvent.setup() + const props = makeProps({ + offer: { type: 'annual_term_extension', data: {}, copy } as OfferDecision, + }) + render() + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() + await user.click(screen.getByText('Extend my term')) + expect(props.onAccept).toHaveBeenCalledWith(undefined) + }) + + it('disables the accept button while processing', () => { + render() + expect(screen.getByText(defaultMessages.common.processing)).toBeDisabled() + }) + + it('calls onDecline from the decline link', async () => { + const user = userEvent.setup() + const props = makeProps() + render() + await user.click(screen.getByText('No thanks')) + expect(props.onDecline).toHaveBeenCalled() + }) +}) + +describe('PassthroughOffer', () => { + it('renders the server copy with accept and decline', () => { + render() + expect(screen.getByText('Stay a little longer')).toBeInTheDocument() + expect(screen.getByText('Extend my term')).toBeInTheDocument() + expect(screen.getByText('No thanks')).toBeInTheDocument() + }) + + it('passes offer.data through onAccept', async () => { + const user = userEvent.setup() + const props = makeProps({ + offer: { type: 'scheduled_transfer', data: { plan: 'annual' }, copy } as OfferDecision, + }) + render() + await user.click(screen.getByText('Extend my term')) + expect(props.onAccept).toHaveBeenCalledWith({ plan: 'annual' }) + }) + + it('accepts with undefined when the offer has no data', async () => { + const user = userEvent.setup() + const props = makeProps({ + offer: { type: 'immediate_transfer', copy } as OfferDecision, + }) + render() + await user.click(screen.getByText('Extend my term')) + expect(props.onAccept).toHaveBeenCalledWith(undefined) + }) + + it('calls onDecline from the decline link', async () => { + const user = userEvent.setup() + const props = makeProps() + render() + await user.click(screen.getByText('No thanks')) + expect(props.onDecline).toHaveBeenCalled() + }) +}) diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 84b3a16..10817c5 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'index': 'src/index.ts', 'headless': 'src/headless/index.ts', 'core': 'src/core/index.ts', + 'recipes': 'src/recipes/index.ts', }, format: ['esm', 'cjs'], dts: true, From c2baf43b99f618c0aaa000576f647c237a97e17d Mon Sep 17 00:00:00 2001 From: Pavel Date: Tue, 21 Jul 2026 22:32:43 +0300 Subject: [PATCH 2/3] Clarify machine comment: direct data also feeds token-mode session payload --- packages/react/src/core/machine.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/core/machine.ts b/packages/react/src/core/machine.ts index 6c32262..6f85f48 100644 --- a/packages/react/src/core/machine.ts +++ b/packages/react/src/core/machine.ts @@ -249,7 +249,8 @@ 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 - // every local mode; analytics additionally needs an appId. + // 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) { From b3ce2a55345b711020fe70cda2b336f3dee9fe89 Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 22 Jul 2026 16:54:23 +0300 Subject: [PATCH 3/3] Address review follow-ups: machine regression test, clearer demo attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a regression test asserting customer/subscriptions seed local-mode state without an appId — the exact case the machine fix restored - Replace the leftover license_plate test attribute in the playground token demo with a self-explanatory plan_tier example --- apps/playground/src/App.tsx | 4 +++- packages/react/tests/core/machine.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/playground/src/App.tsx b/apps/playground/src/App.tsx index c343375..38cfa95 100644 --- a/apps/playground/src/App.tsx +++ b/apps/playground/src/App.tsx @@ -658,7 +658,9 @@ function TokenModeDemo() { { const machine = new CancelFlowMachine(baseConfig) expect(machine.currentOffer).toBeNull() }) + + // Regression: these props used to be stored only when appId was set, so + // pure local-mode components never saw customer or subscription data. + it('seeds customer and subscriptions into state in local mode without appId', () => { + const machine = new CancelFlowMachine({ + ...baseConfig, + customer: { id: 'cus_1', email: 'c@example.com' }, + subscriptions: [ + { + id: 'sub_1', + start: '2026-01-01T00:00:00Z', + status: { name: 'active', currentPeriod: { start: '2026-07-01T00:00:00Z', end: '2026-08-01T00:00:00Z' } }, + items: [], + }, + ], + }) + const state = machine.getSnapshot() + expect(state.customer?.id).toBe('cus_1') + expect(state.subscriptions).toHaveLength(1) + expect(state.subscriptions[0].id).toBe('sub_1') + }) }) describe('selectReason', () => {