diff --git a/apps/playground/src/TestHarness.tsx b/apps/playground/src/TestHarness.tsx index 43c3193..7386b74 100644 --- a/apps/playground/src/TestHarness.tsx +++ b/apps/playground/src/TestHarness.tsx @@ -59,7 +59,7 @@ const SCENARIOS: { id: Scenario; label: string; description: string }[] = [ { id: 'all-offers', label: 'All Built-in Offer Types', - description: 'discount, pause, plan_change, trial_extension, contact, redirect — one reason each.', + description: 'discount, pause, plan_change, trial_extension, contact, redirect, rebate — one reason each.', }, { id: 'standalone-offer', @@ -194,6 +194,19 @@ const allOffersSteps: Step[] = [ label: 'Learn more (→ redirect)', offer: { type: 'redirect', url: 'https://example.com/docs', label: 'See docs' }, }, + { + id: 'rebate', + label: 'Within the guarantee window (→ rebate)', + offer: { + type: 'rebate', + amountMinor: 500, + currency: 'USD', + amountPaidMinor: 10890, + netAfterRebateMinor: 10390, + paymentMethodBrand: 'visa', + paymentMethodLast4: '4242', + }, + }, { id: 'none', label: 'No reason (no offer)' }, ], }, diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index c2d836a..d55cfd7 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -2,6 +2,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Expect breaking changes in minor versions while we're pre-1.0. +## 0.4.0 — 2026-05-29 + +### Added + +- **Rebate offer type.** A `rebate` is a partial refund of the customer's most recent paid invoice while their subscription stays active — aimed at money-back-guarantee windows, where a customer who would cancel to get their money back can take a partial refund and stay instead. + - The `'rebate'` offer type joins the config union — the local shape plus the fields resolved in token mode (`amountMinor`, `currency`, `amountPaidMinor`, `netAfterRebateMinor`) — wired through the state machine, the config transform, and the offer-type map. + - `DefaultRebateOffer` renders the offer as an itemized invoice: the period charge, the rebate being credited, and what's still due, with the rebate line accented. Overridable via `components.RebateOffer`. + - `handleRebate` / `onRebate` callbacks, matching the other offer types. In connected mode the SDK runs the rebate server-side; defining `handleRebate` overrides that to run the refund through your own billing. + - The accepted rebate amount is recorded on the session. + +### Changed + +- Offer panels now share one surface. The discount and trial-extension panels use `colorSurfaceMuted` (the neutral callout surface) instead of `colorPrimarySoft` (the indigo tint), so every offer panel reads consistently and `colorPrimarySoft` is reserved for selected-state highlights as documented. Override the relevant `--ck-*` properties to restore the previous tint. + ## 0.3.0 — 2026-05-19 ### Added diff --git a/packages/react/package.json b/packages/react/package.json index 1973ed1..8b1a092 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@churnkey/react", - "version": "0.3.0", + "version": "0.4.0", "description": "Production-ready cancel flow for React. Drop-in component, headless hook, or full customization. Works standalone or with Churnkey for AI-powered retention.", "license": "MIT", "repository": { diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index b6f84f5..0c9a912 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -10,6 +10,7 @@ export { DefaultContactOffer } from './steps/offer/default-contact-offer' export { DefaultDiscountOffer } from './steps/offer/default-discount-offer' export { DefaultPauseOffer } from './steps/offer/default-pause-offer' export { DefaultPlanChangeOffer } from './steps/offer/default-plan-change-offer' +export { DefaultRebateOffer } from './steps/offer/default-rebate-offer' export { DefaultRedirectOffer } from './steps/offer/default-redirect-offer' export { DefaultTrialExtensionOffer } from './steps/offer/default-trial-extension-offer' // Structural diff --git a/packages/react/src/components/steps/default-offer.tsx b/packages/react/src/components/steps/default-offer.tsx index ac01ed2..75144ba 100644 --- a/packages/react/src/components/steps/default-offer.tsx +++ b/packages/react/src/components/steps/default-offer.tsx @@ -4,6 +4,7 @@ import { DefaultContactOffer } from './offer/default-contact-offer' import { DefaultDiscountOffer } from './offer/default-discount-offer' import { DefaultPauseOffer } from './offer/default-pause-offer' import { DefaultPlanChangeOffer } from './offer/default-plan-change-offer' +import { DefaultRebateOffer } from './offer/default-rebate-offer' import { DefaultRedirectOffer } from './offer/default-redirect-offer' import { DefaultTrialExtensionOffer } from './offer/default-trial-extension-offer' @@ -43,6 +44,8 @@ function pickOfferComponent( return components?.ContactOffer ?? DefaultContactOffer case 'redirect': return components?.RedirectOffer ?? DefaultRedirectOffer + case 'rebate': + return components?.RebateOffer ?? DefaultRebateOffer default: return null } diff --git a/packages/react/src/components/steps/offer/default-rebate-offer.tsx b/packages/react/src/components/steps/offer/default-rebate-offer.tsx new file mode 100644 index 0000000..6b03118 --- /dev/null +++ b/packages/react/src/components/steps/offer/default-rebate-offer.tsx @@ -0,0 +1,70 @@ +import { formatPriceFromMinor } from '../../../core/format' +import type { OfferDecision, OfferStepProps } from '../../../core/types' +import { cn } from '../../../core/utils' +import { RichText } from '../../rich-text' + +type RebateDecision = OfferDecision & { + amountMinor?: number + currency?: string + amountPaidMinor?: number + netAfterRebateMinor?: number +} + +export function DefaultRebateOffer({ + title, + description, + offer, + onAccept, + onDecline, + isProcessing, + classNames, +}: OfferStepProps) { + const o = offer as RebateDecision + const headline = title ?? offer.copy.headline + const body = description ?? offer.copy.body + const currency = o.currency ?? 'usd' + const amount = o.amountMinor ?? 0 + + return ( +
+ {headline &&

{headline}

} + {body && } + +
+ {/* Itemized like an invoice: the period charge, the rebate we credit + (the accented line), and what's still due. Paid and net are + server-resolved in token mode, so each renders only when present. */} +
+ {o.amountPaidMinor != null && ( +
+ Subscription · this period + {formatPriceFromMinor(o.amountPaidMinor, currency)} +
+ )} +
+ Cancellation rebate + −{formatPriceFromMinor(amount, currency)} +
+ {o.netAfterRebateMinor != null && ( +
+ Due for this period + {formatPriceFromMinor(o.netAfterRebateMinor, currency)} +
+ )} +
+ + + +
+
+ ) +} diff --git a/packages/react/src/core/api-types.ts b/packages/react/src/core/api-types.ts index 9ab12be..42be6ad 100644 --- a/packages/react/src/core/api-types.ts +++ b/packages/react/src/core/api-types.ts @@ -65,6 +65,7 @@ export type SdkOffer = | SdkTrialExtensionOffer | SdkRedirectOffer | SdkContactOffer + | SdkRebateOffer interface SdkOfferBase { /** Per-offer guid — used for analytics joins between presented and accepted offers. */ @@ -111,6 +112,19 @@ export interface SdkContactOffer extends SdkOfferBase { label?: string } +export interface SdkRebateOffer extends SdkOfferBase { + type: 'rebate' + /** Cash refunded to the card, smallest currency unit. */ + amountMinor: number + currency: string + /** Gross amount paid on the target invoice — the "you paid" row. */ + amountPaidMinor: number + /** amountPaidMinor − amountMinor — the "your net" row. */ + netAfterRebateMinor: number + paymentMethodBrand?: string + paymentMethodLast4?: string +} + export interface SdkOfferCopy { headline: string body: string diff --git a/packages/react/src/core/api.ts b/packages/react/src/core/api.ts index d155fe6..8b47c5a 100644 --- a/packages/react/src/core/api.ts +++ b/packages/react/src/core/api.ts @@ -7,7 +7,15 @@ const DEFAULT_BASE_URL = 'https://api.churnkey.co/v1' // Wire enums accepted by the API. Literal unions so payload builders fail // at compile time if they emit a value outside the accepted set. export type ApiStepType = 'OFFER' | 'SURVEY' | 'CONFIRM' | 'FREEFORM' | 'CUSTOM' -export type ApiOfferType = 'DISCOUNT' | 'PAUSE' | 'PLAN_CHANGE' | 'TRIAL_EXTENSION' | 'CONTACT' | 'REDIRECT' | 'CUSTOM' +export type ApiOfferType = + | 'DISCOUNT' + | 'PAUSE' + | 'PLAN_CHANGE' + | 'TRIAL_EXTENSION' + | 'CONTACT' + | 'REDIRECT' + | 'REBATE' + | 'CUSTOM' export type ApiPauseInterval = 'MONTH' | 'WEEK' export type ApiCouponType = 'PERCENT' | 'AMOUNT' export type ApiBillingInterval = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR' @@ -54,6 +62,7 @@ export interface AcceptedOfferPayload { newPlanPrice?: number trialExtensionDays?: number redirectUrl?: string + rebateAmount?: number } export interface SessionCustomer { @@ -174,6 +183,10 @@ export class ChurnkeyApi { await this.request(this.orgUrl('cancel-flow/actions/extend-trial'), { days, blueprintId }) } + async applyRebate(blueprintId?: string, offerGuid?: string): Promise { + await this.request(this.orgUrl('cancel-flow/actions/rebate'), { blueprintId, offerGuid }) + } + async createSession(payload: SessionPayload): Promise { await this.request(`${this.baseUrl}/api/sessions/sdk`, payload) } diff --git a/packages/react/src/core/index.ts b/packages/react/src/core/index.ts index e37197b..97b805d 100644 --- a/packages/react/src/core/index.ts +++ b/packages/react/src/core/index.ts @@ -61,6 +61,7 @@ export type { PlanOption, ReasonButtonProps, ReasonConfig, + RebateOffer, RedirectOffer, Step, StructuralClassNames, diff --git a/packages/react/src/core/machine.ts b/packages/react/src/core/machine.ts index 8f4c291..e112da6 100644 --- a/packages/react/src/core/machine.ts +++ b/packages/react/src/core/machine.ts @@ -40,6 +40,8 @@ function handlerFor(offerType: string, cb: FlowCallbacks): OfferCallback | undef return cb.handlePlanChange case 'trial_extension': return cb.handleTrialExtension + case 'rebate': + return cb.handleRebate default: return undefined } @@ -55,6 +57,8 @@ function listenerFor(offerType: string, cb: FlowCallbacks): OfferCallback | unde return cb.onPlanChange case 'trial_extension': return cb.onTrialExtension + case 'rebate': + return cb.onRebate default: return undefined } @@ -93,6 +97,7 @@ const OFFER_TYPE_API_MAP: Record { + private async executeTokenAction(offer: AcceptedOffer, decisionId?: string): Promise { if (!this.apiClient) return // Only built-in offer types have a server action; custom types route @@ -569,6 +576,9 @@ export class CancelFlowMachine { case 'trial_extension': await this.apiClient.extendTrial(o.days, this.blueprintId ?? undefined) break + case 'rebate': + await this.apiClient.applyRebate(this.blueprintId ?? undefined, decisionId) + break } } diff --git a/packages/react/src/core/transform.ts b/packages/react/src/core/transform.ts index 851cd6c..0c36a1c 100644 --- a/packages/react/src/core/transform.ts +++ b/packages/react/src/core/transform.ts @@ -117,6 +117,16 @@ function transformOfferConfig(o: SdkOffer): OfferConfig { url: o.url, label: o.label, } + case 'rebate': + return { + type: 'rebate', + amountMinor: o.amountMinor, + currency: o.currency, + amountPaidMinor: o.amountPaidMinor, + netAfterRebateMinor: o.netAfterRebateMinor, + paymentMethodBrand: o.paymentMethodBrand, + paymentMethodLast4: o.paymentMethodLast4, + } } } @@ -170,6 +180,13 @@ export function defaultOfferCopy(offer: OfferConfig): OfferCopy { cta: o.label, declineCta: 'No thanks', } + case 'rebate': + return { + headline: 'Get money back', + body: "Stay subscribed and we'll refund part of your most recent payment.", + cta: 'Accept refund', + declineCta: 'No thanks', + } default: return { headline: 'Before you go...', diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index 6601a98..c362056 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -161,6 +161,19 @@ export interface RedirectOffer { label: string } +export interface RebateOffer { + type: 'rebate' + /** Cash refunded to the card, smallest currency unit. */ + amountMinor: number + currency: string + /** Gross paid on the target invoice. Server-resolved in token mode. */ + amountPaidMinor?: number + /** amountPaidMinor − amountMinor. Server-resolved in token mode. */ + netAfterRebateMinor?: number + paymentMethodBrand?: string + paymentMethodLast4?: string +} + export interface CustomOfferConfig { type: string data?: Record @@ -173,6 +186,7 @@ export type BuiltInOfferConfig = | TrialExtensionOffer | ContactOffer | RedirectOffer + | RebateOffer export type OfferConfig = BuiltInOfferConfig | CustomOfferConfig export type OfferDecision = OfferConfig & { copy: OfferCopy; decisionId?: string } @@ -480,6 +494,7 @@ export interface ComponentOverrides { TrialExtensionOffer?: (props: OfferStepProps) => ReactElement ContactOffer?: (props: OfferStepProps) => ReactElement RedirectOffer?: (props: OfferStepProps) => ReactElement + RebateOffer?: (props: OfferStepProps) => ReactElement } export type CustomComponents = Record | ComponentType> @@ -648,6 +663,7 @@ export interface FlowCallbacks { handlePause?: OfferCallback handlePlanChange?: OfferCallback handleTrialExtension?: OfferCallback + handleRebate?: OfferCallback handleCancel?: CancelCallback onAccept?: OfferCallback @@ -655,6 +671,7 @@ export interface FlowCallbacks { onPause?: OfferCallback onPlanChange?: OfferCallback onTrialExtension?: OfferCallback + onRebate?: OfferCallback onCancel?: CancelCallback onClose?: () => void onStepChange?: (step: string, prevStep: string) => void diff --git a/packages/react/src/core/utils.ts b/packages/react/src/core/utils.ts index ff1b2a1..4df0c52 100644 --- a/packages/react/src/core/utils.ts +++ b/packages/react/src/core/utils.ts @@ -9,6 +9,7 @@ export const BUILT_IN_OFFER_TYPES: readonly string[] = [ 'plan_change', 'contact', 'redirect', + 'rebate', ] export function cn(...classes: (string | undefined | null | false)[]): string { diff --git a/packages/react/src/styles/cancel-flow.css b/packages/react/src/styles/cancel-flow.css index cc377e7..a6d7d94 100644 --- a/packages/react/src/styles/cancel-flow.css +++ b/packages/react/src/styles/cancel-flow.css @@ -431,10 +431,17 @@ margin-bottom: 20px; } -/* Discount: centered phrase in a soft tinted panel. */ -.ck-cancel-flow .ck-offer-discount { - background: var(--ck-color-primary-soft); +/* Offer panels are soft callouts (colorSurfaceMuted), not selected-state + highlights (colorPrimarySoft). Shared so the offer types stay consistent. */ +.ck-cancel-flow .ck-offer-discount, +.ck-cancel-flow .ck-pause-card, +.ck-cancel-flow .ck-trial-block { + background: var(--ck-color-surface-muted); border-radius: var(--ck-radius-lg); +} + +/* Discount: centered phrase. */ +.ck-cancel-flow .ck-offer-discount { padding: 24px 20px; text-align: center; } @@ -457,11 +464,43 @@ color: var(--ck-color-text); } +/* Rebate: an itemized invoice for the period — the charge, the rebate we + credit (accented), and what's still due. Lines sit on the surface rather + than in a filled panel. */ +.ck-cancel-flow .ck-offer-rebate { + margin: 4px 0 26px; + font-variant-numeric: tabular-nums; +} + +.ck-cancel-flow .ck-offer-rebate-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 7px 0; + font-size: 13px; + color: var(--ck-color-text-muted); +} + +/* The rebate line — the figure we want the eye to land on. */ +.ck-cancel-flow .ck-offer-rebate-credit { + font-size: 14px; + font-weight: 600; + color: var(--ck-color-primary); +} + +.ck-cancel-flow .ck-offer-rebate-total { + margin-top: 6px; + padding-top: 14px; + border-top: 1px solid var(--ck-color-border); + font-size: 15px; + font-weight: 600; + color: var(--ck-color-text); +} + /* Pause: resume date is the typographic anchor, month chips below. */ .ck-cancel-flow .ck-pause-card { padding: 32px 24px 22px; - background: var(--ck-color-surface-muted); - border-radius: var(--ck-radius-lg); text-align: center; margin: 8px 0 20px; } @@ -515,14 +554,12 @@ border-color: var(--ck-color-primary); } -/* Trial extension: day count badge + new end date in accent-soft container. */ +/* Trial extension: day count badge + new end date. */ .ck-cancel-flow .ck-trial-block { display: flex; align-items: center; gap: 16px; padding: 20px; - background: var(--ck-color-primary-soft); - border-radius: var(--ck-radius-lg); } .ck-cancel-flow .ck-trial-badge { diff --git a/packages/react/tests/core/api.test.ts b/packages/react/tests/core/api.test.ts index e4c22d5..f7c9c69 100644 --- a/packages/react/tests/core/api.test.ts +++ b/packages/react/tests/core/api.test.ts @@ -4,8 +4,6 @@ import type { SessionCredentials } from '../../src/core/token' // Pins the action path and body shape for every cancel-flow action so a // server/SDK drift produces a test failure rather than a runtime 404. -// Backend alias routes live in churnkey-api/src/api/org/index.js alongside -// the legacy /stripe/* paths the hosted embed still uses. const creds: SessionCredentials = { appId: 'app_test', diff --git a/packages/react/tests/core/machine.test.ts b/packages/react/tests/core/machine.test.ts index d4a9b3b..abf8515 100644 --- a/packages/react/tests/core/machine.test.ts +++ b/packages/react/tests/core/machine.test.ts @@ -354,6 +354,60 @@ describe('CancelFlowMachine', () => { }) }) + describe('rebate offer', () => { + const rebateSteps = { + steps: [ + { + type: 'survey' as const, + reasons: [ + { + id: 'price', + label: 'Too expensive', + offer: { type: 'rebate' as const, amountMinor: 1000, currency: 'usd' }, + }, + ], + }, + { type: 'confirm' as const }, + ], + } + + it('routes the accepted offer to handleRebate in local mode', async () => { + const handleRebate = vi.fn() + const machine = new CancelFlowMachine({ ...rebateSteps, handleRebate }) + machine.selectReason('price') + machine.next() + await machine.accept() + + expect(handleRebate).toHaveBeenCalledWith( + expect.objectContaining({ type: 'rebate', amountMinor: 1000, currency: 'usd', reasonId: 'price' }), + null, + ) + expect(machine.getSnapshot().step).toBe('success') + }) + + it('token mode calls applyRebate with the blueprint id', async () => { + const mockApi = { + applyRebate: vi.fn(async (_blueprintId?: string, _offerGuid?: string) => {}), + cancelSubscription: vi.fn(async () => {}), + createSession: vi.fn(async () => {}), + } + const machine = new CancelFlowMachine({ ...rebateSteps, session: 'ck_placeholder' }) + machine.initializeFromConfig(sdkConfig({ blueprintId: 'bp_1' }), mockApi as any, { + appId: 'a', + customerId: 'c', + authHash: 'h', + mode: 'live' as const, + issuedAt: 0, + }) + machine.selectReason('price') + machine.next() + await machine.accept() + + expect(mockApi.applyRebate).toHaveBeenCalledOnce() + expect(mockApi.applyRebate.mock.calls[0][0]).toBe('bp_1') + }) + }) + describe('decline', () => { it('moves to the declared next step after the offer', () => { const machine = new CancelFlowMachine(baseConfig) diff --git a/packages/react/tests/core/transform.test.ts b/packages/react/tests/core/transform.test.ts index 0028663..aac7ab9 100644 --- a/packages/react/tests/core/transform.test.ts +++ b/packages/react/tests/core/transform.test.ts @@ -87,6 +87,42 @@ describe('transformSdkConfig', () => { expect(offerStep.offer.copy).toEqual({ headline: 'h', body: 'b', cta: 'cta', declineCta: 'no' }) }) + it('preserves resolved rebate fields on the step-attached offer', () => { + const config: SdkConfig = { + blueprintId: 'bp_1', + steps: [ + { + type: 'offer', + guid: 'o1', + offer: { + type: 'rebate', + amountMinor: 1000, + currency: 'usd', + amountPaidMinor: 9900, + netAfterRebateMinor: 8900, + paymentMethodBrand: 'Visa', + paymentMethodLast4: '4242', + copy: { headline: 'h', body: 'b', cta: 'cta', declineCta: 'no' }, + }, + }, + ], + customer: { id: 'cus_1' }, + subscriptions: [], + settings: { clickToCancelEnabled: false, strictFTCComplianceEnabled: false }, + } + + const { steps } = transformSdkConfig(config) + const offer = ( + steps[0] as { + offer: { type: string; amountMinor: number; netAfterRebateMinor?: number; paymentMethodLast4?: string } + } + ).offer + expect(offer.type).toBe('rebate') + expect(offer.amountMinor).toBe(1000) + expect(offer.netAfterRebateMinor).toBe(8900) + expect(offer.paymentMethodLast4).toBe('4242') + }) + it('inlines plan_change plans as DirectPrice[]', () => { const config: SdkConfig = { blueprintId: 'bp_1',