diff --git a/apps/playground/src/App.tsx b/apps/playground/src/App.tsx
index 7d38957..38cfa95 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,14 @@ function TokenModeDemo() {
console.log('Accepted:', offer)}
onCancel={async () => console.log('Cancelled')}
onClose={() => setOpen(false)}
@@ -584,6 +693,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..6f85f48 100644
--- a/packages/react/src/core/machine.ts
+++ b/packages/react/src/core/machine.ts
@@ -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
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/machine.test.ts b/packages/react/tests/core/machine.test.ts
index 546ab4b..3e7c8fa 100644
--- a/packages/react/tests/core/machine.test.ts
+++ b/packages/react/tests/core/machine.test.ts
@@ -59,6 +59,27 @@ describe('CancelFlowMachine', () => {
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', () => {
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,