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
15 changes: 14 additions & 1 deletion apps/playground/src/TestHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)' },
],
},
Expand Down
14 changes: 14 additions & 0 deletions packages/react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/components/steps/default-offer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
}
Expand Down
70 changes: 70 additions & 0 deletions packages/react/src/components/steps/offer/default-rebate-offer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn('ck-step ck-step-offer', classNames?.root)}>
{headline && <h2 className={cn('ck-step-title', classNames?.title)}>{headline}</h2>}
{body && <RichText html={body} className={cn('ck-step-description', classNames?.description)} />}

<div className={cn('ck-offer-card', classNames?.card)}>
{/* 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. */}
<div className="ck-offer-rebate">
{o.amountPaidMinor != null && (
<div className="ck-offer-rebate-row">
<span>Subscription · this period</span>
<span>{formatPriceFromMinor(o.amountPaidMinor, currency)}</span>
</div>
)}
<div className="ck-offer-rebate-row ck-offer-rebate-credit">
<span>Cancellation rebate</span>
<span>−{formatPriceFromMinor(amount, currency)}</span>
</div>
{o.netAfterRebateMinor != null && (
<div className="ck-offer-rebate-row ck-offer-rebate-total">
<span>Due for this period</span>
<span>{formatPriceFromMinor(o.netAfterRebateMinor, currency)}</span>
</div>
)}
</div>

<button
type="button"
className={cn('ck-button ck-button-primary', classNames?.acceptButton)}
onClick={() => onAccept()}
disabled={isProcessing}
>
{isProcessing ? 'Processing...' : offer.copy.cta}
</button>
<button type="button" className={cn('ck-button-link', classNames?.declineButton)} onClick={onDecline}>
{offer.copy.declineCta}
</button>
</div>
</div>
)
}
14 changes: 14 additions & 0 deletions packages/react/src/core/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion packages/react/src/core/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -54,6 +62,7 @@ export interface AcceptedOfferPayload {
newPlanPrice?: number
trialExtensionDays?: number
redirectUrl?: string
rebateAmount?: number
}

export interface SessionCustomer {
Expand Down Expand Up @@ -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<void> {
await this.request(this.orgUrl('cancel-flow/actions/rebate'), { blueprintId, offerGuid })
}

async createSession(payload: SessionPayload): Promise<void> {
await this.request(`${this.baseUrl}/api/sessions/sdk`, payload)
}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type {
PlanOption,
ReasonButtonProps,
ReasonConfig,
RebateOffer,
RedirectOffer,
Step,
StructuralClassNames,
Expand Down
14 changes: 12 additions & 2 deletions packages/react/src/core/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -93,6 +97,7 @@ const OFFER_TYPE_API_MAP: Record<BuiltInOfferConfig['type'], Exclude<ApiOfferTyp
trial_extension: 'TRIAL_EXTENSION',
contact: 'CONTACT',
redirect: 'REDIRECT',
rebate: 'REBATE',
}

// 'success' isn't a stepsViewed entry — the session's outcome/canceled/
Expand Down Expand Up @@ -186,6 +191,8 @@ function toAcceptedOfferPayload(rec: OfferDecision, result?: Record<string, unkn
return { ...base, trialExtensionDays: o.days }
case 'redirect':
return { ...base, redirectUrl: o.url }
case 'rebate':
return { ...base, rebateAmount: o.amountMinor }
default:
return base
}
Expand Down Expand Up @@ -361,7 +368,7 @@ export class CancelFlowMachine {
if (handler) {
await handler(acceptedOffer, customer)
} else if (this.isTokenMode()) {
await this.executeTokenAction(acceptedOffer)
await this.executeTokenAction(acceptedOffer, offer.decisionId)
}

// Listeners fire after the action succeeded, regardless of who ran it.
Expand Down Expand Up @@ -548,7 +555,7 @@ export class CancelFlowMachine {
}
}

private async executeTokenAction(offer: AcceptedOffer): Promise<void> {
private async executeTokenAction(offer: AcceptedOffer, decisionId?: string): Promise<void> {
if (!this.apiClient) return

// Only built-in offer types have a server action; custom types route
Expand All @@ -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
}
}

Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}

Expand Down Expand Up @@ -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...',
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
Expand All @@ -173,6 +186,7 @@ export type BuiltInOfferConfig =
| TrialExtensionOffer
| ContactOffer
| RedirectOffer
| RebateOffer
export type OfferConfig = BuiltInOfferConfig | CustomOfferConfig

export type OfferDecision = OfferConfig & { copy: OfferCopy; decisionId?: string }
Expand Down Expand Up @@ -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<string, ComponentType<CustomStepProps> | ComponentType<CustomOfferProps>>
Expand Down Expand Up @@ -648,13 +663,15 @@ export interface FlowCallbacks {
handlePause?: OfferCallback
handlePlanChange?: OfferCallback
handleTrialExtension?: OfferCallback
handleRebate?: OfferCallback
handleCancel?: CancelCallback

onAccept?: OfferCallback
onDiscount?: OfferCallback
onPause?: OfferCallback
onPlanChange?: OfferCallback
onTrialExtension?: OfferCallback
onRebate?: OfferCallback
onCancel?: CancelCallback
onClose?: () => void
onStepChange?: (step: string, prevStep: string) => void
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading