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
2 changes: 2 additions & 0 deletions apps/playground/src/TestHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ const i18nSteps: Step[] = [
type: 'survey',
reasons: [
{ id: 'expensive', label: 'Too expensive', offer: { type: 'discount', percentOff: 20, durationInMonths: 3 } },
// Pause demonstrates per-offer success copy incl. the {resumeDate} token.
{ id: 'busy', label: 'Too busy right now', offer: { type: 'pause', months: 3 } },
{ id: 'missing', label: 'Missing features' },
],
},
Expand Down
40 changes: 36 additions & 4 deletions packages/react/src/components/cancel-flow.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { type ReactElement, useEffect } from 'react'
import { formatPeriodEnd } from '../core/format'
import { formatPeriodEnd, formatShortDate } from '../core/format'
import type { CancelFlowMachine } from '../core/machine'
import { type CancelFlowMessages, formatMessage, selectTiming } from '../core/messages'
import { type CancelFlowMessages, formatMessage, type SavedOfferCopy, selectTiming } from '../core/messages'
import type {
AcceptedOffer,
CancelFlowProps,
ComponentOverrides,
ConfirmStep,
Expand Down Expand Up @@ -297,18 +298,23 @@ function StepRenderer({
const Success = components?.Success ?? DefaultSuccess
const config = stepConfig as SuccessStep | undefined
const isSaved = state.outcome === 'saved'
const perOffer = isSaved ? savedOfferCopy(messages, state.acceptedOffer) : undefined
return (
<Success
outcome={state.outcome ?? 'cancelled'}
offer={machine.currentOffer ?? undefined}
acceptedOffer={state.acceptedOffer ?? undefined}
title={
isSaved
? (config?.savedTitle ?? messages.success.saved.title)
? (config?.savedTitle ?? perOffer?.title ?? messages.success.saved.title)
: (config?.cancelledTitle ?? selectTiming(messages.success.cancelled.title, state.cancelAtPeriodEnd))
}
description={
isSaved
? (config?.savedDescription ?? messages.success.saved.description)
? (config?.savedDescription ??
pauseResumeDescription(messages, state.acceptedOffer) ??
perOffer?.description ??
messages.success.saved.description)
: (config?.cancelledDescription ??
selectTiming(messages.success.cancelled.description, state.cancelAtPeriodEnd))
}
Expand Down Expand Up @@ -348,6 +354,32 @@ function StepRenderer({
}
}

// Offer types with their own success copy. Others (contact, redirect,
// custom types) fall back to the generic saved copy.
const SAVED_OFFER_COPY_TYPES = ['discount', 'pause', 'trial_extension', 'plan_change', 'rebate'] as const

function savedOfferCopy(messages: CancelFlowMessages, offer: AcceptedOffer | null): SavedOfferCopy | undefined {
if (!offer) return undefined
if (!(SAVED_OFFER_COPY_TYPES as readonly string[]).includes(offer.type)) return undefined
return messages.success.saved[offer.type as (typeof SAVED_OFFER_COPY_TYPES)[number]]
}

// The built-in pause UI always passes the selected duration on accept;
// without it there's no resume date to promise, so the plain description
// renders instead.
function pauseResumeDescription(messages: CancelFlowMessages, offer: AcceptedOffer | null): string | undefined {
if (offer?.type !== 'pause') return undefined
const months = offer.result?.months
if (typeof months !== 'number' || months < 1) return undefined
const resume = new Date()
if ((offer as { interval?: 'month' | 'week' }).interval === 'week') {
resume.setDate(resume.getDate() + months * 7)
} else {
resume.setMonth(resume.getMonth() + months)
}
return formatMessage(messages.success.saved.pause.resumeDescription, { resumeDate: formatShortDate(resume) })
}

// The notice makes a factual claim about billing behavior, so it requires the
// timing to be KNOWN to be period-end — server-resolved in token mode, or the
// developer's explicit cancelAtPeriodEnd declaration in local mode. `null`
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/core/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// as input, so consumers see one consistent type. Steps and offers arrive
// fully resolved — coupon ids already hydrated, plan ids already inlined.

import type { MessagesPatch } from './messages'
import type { DirectCustomer, DirectSubscription, PlanOption } from './types'

export interface SdkConfig {
Expand All @@ -11,6 +12,13 @@ export interface SdkConfig {
customer: DirectCustomer
subscriptions: DirectSubscription[]
settings: SdkSettings
/**
* Org-level message overrides configured in the dashboard, keyed by
* language. All configured languages ship; the client picks via its locale
* chain. Merged between the built-in defaults and the developer's
* `i18n.messages`.
*/
translations?: Record<string, MessagesPatch>
/** Bandit key used for auto-optimization, if it ran. */
autoOptimizationKey?: string
}
Expand Down
9 changes: 8 additions & 1 deletion packages/react/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ export type {
} from './api-types'
export { calculateDiscountedPrice, formatPeriodEnd, formatPrice } from './format'
export { CancelFlowMachine } from './machine'
export type { CancelFlowMessages, I18nConfig, MessagesPatch, TimingAware, TimingVariants } from './messages'
export type {
CancelFlowMessages,
I18nConfig,
MessagesPatch,
SavedOfferCopy,
TimingAware,
TimingVariants,
} from './messages'
export { buildMessages, defaultMessages, formatMessage, selectTiming } from './messages'
export type { ResolvedStep } from './step-graph'
export type { SessionCredentials } from './token'
Expand Down
25 changes: 21 additions & 4 deletions packages/react/src/core/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
import { AnalyticsClient, directDataToSessionCustomer, toApiMode } from './api'
import type { SdkConfig } from './api-types'
import { applyMergeFieldsToSteps } from './merge-fields'
import { buildMessages, type CancelFlowMessages } from './messages'
import { buildMessages, type CancelFlowMessages, type I18nConfig } from './messages'
import { buildStepGraph, type ResolvedStep, type StepGraph } from './step-graph'
import type { SessionCredentials } from './token'
import { defaultOfferCopy, transformSdkConfig } from './transform'
Expand Down Expand Up @@ -222,6 +222,7 @@ export class CancelFlowMachine {
private presentedOffers: PresentedOffer[] = []
private customStepResults: Record<string, unknown> = {}
private configMode: Mode = 'live'
private i18nConfig: I18nConfig | undefined
private localCancelAtPeriodEnd: boolean | null = null
private resolvedMessages: CancelFlowMessages
private stepEnteredAt: number = Date.now()
Expand All @@ -235,6 +236,7 @@ export class CancelFlowMachine {
// FlowConfig extends FlowCallbacks; storing the whole config covers
// every callback by name without a separate copy.
this.callbacks = config
this.i18nConfig = config.i18n
this.resolvedMessages = buildMessages(config.i18n)
// Local mode only — in token mode the server-resolved value is
// authoritative, and when the server omits it the timing must stay
Expand Down Expand Up @@ -402,7 +404,7 @@ export class CancelFlowMachine {
await this.callbacks.onAccept?.(acceptedOffer, customer)

this.markCurrentOfferAccepted()
this.enterSuccessStep('saved')
this.enterSuccessStep('saved', acceptedOffer)
this.recordOutcome('saved', offer, safeResult)
} catch (error) {
this.setState({ isProcessing: false, error: error as Error })
Expand Down Expand Up @@ -460,6 +462,12 @@ export class CancelFlowMachine {
this.creds = creds
this.config = config

// Re-resolve with the org's dashboard-configured overrides, which ride
// along on the config payload. Developer i18n.messages still win.
if (config.translations) {
this.resolvedMessages = buildMessages(this.i18nConfig, config.translations)
}

const result = transformSdkConfig(config)
this.blueprintId = result.blueprintId

Expand Down Expand Up @@ -502,6 +510,7 @@ export class CancelFlowMachine {
customer,
subscriptions,
cancelAtPeriodEnd: this.config?.settings.cancelAtPeriodEnd ?? this.localCancelAtPeriodEnd,
acceptedOffer: null,
}
}

Expand Down Expand Up @@ -653,9 +662,17 @@ export class CancelFlowMachine {
// Terminal transition. Prefer moving currentStepId to a declared success
// step so the developer's savedTitle / classNames / custom component
// applies; otherwise stay put and the renderer's defaults cover it.
private enterSuccessStep(outcome: 'saved' | 'cancelled'): void {
// The accepted offer is snapshotted into state here because currentOffer
// derives from currentStepId, which a declared success step moves off the
// offer — per-offer success copy needs a stable source.
private enterSuccessStep(outcome: 'saved' | 'cancelled', acceptedOffer?: AcceptedOffer): void {
const success = this.getStepConfig('success')
const partial: Partial<FlowState> = { step: 'success', outcome, isProcessing: false }
const partial: Partial<FlowState> = {
step: 'success',
outcome,
isProcessing: false,
acceptedOffer: acceptedOffer ?? null,
}
if (success) partial.currentStepId = success.guid
this.setState(partial)
}
Expand Down
133 changes: 98 additions & 35 deletions packages/react/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface TimingVariants {
*/
export type TimingAware = string | TimingVariants

/** Success-screen copy for one accepted offer type. */
export interface SavedOfferCopy {
title: string
description: string
}

export interface CancelFlowMessages {
common: {
continue: string
Expand Down Expand Up @@ -61,8 +67,19 @@ export interface CancelFlowMessages {
}
success: {
saved: {
/** Generic fallback — used for offer types without their own entry
* (contact, redirect, custom types). */
title: string
description: string
discount: SavedOfferCopy
pause: SavedOfferCopy & {
/** Used instead of `description` when the resume date is known
* (the built-in pause UI always supplies it). Supports `{resumeDate}`. */
resumeDescription: string
}
trial_extension: SavedOfferCopy
plan_change: SavedOfferCopy
rebate: SavedOfferCopy
}
cancelled: {
title: TimingAware
Expand Down Expand Up @@ -143,9 +160,20 @@ export const defaultMessages: CancelFlowMessages = {
},
},
success: {
// Per-offer defaults mirror the embed's *Applied keys so the two
// surfaces confirm the same outcome with the same words.
saved: {
title: 'Welcome back!',
description: 'Your offer has been applied.',
discount: { title: 'Discount applied.', description: "We're so happy you're still here." },
pause: {
title: 'Subscription paused.',
description: "You won't be billed until your subscription resumes.",
resumeDescription: "You won't be billed again until {resumeDate}.",
},
trial_extension: { title: 'Trial extended.', description: 'Your trial has been extended successfully.' },
plan_change: { title: 'Plan changed.', description: 'Your new plan is now in effect.' },
rebate: { title: 'Refund issued.', description: "Your money's on its way back." },
},
cancelled: {
title: 'Subscription cancelled',
Expand Down Expand Up @@ -194,26 +222,56 @@ export function formatMessage(template: string, vars: Record<string, string | nu
})
}

// Empty-string overrides are dropped (a blank dashboard field must not
// clobber a real string — same rule as the embed), which is why defaults
// may legitimately hold '' but patches never land one.
function mergeValue(base: unknown, patch: unknown): unknown {
if (typeof patch === 'string') return patch === '' ? base : patch
if (isTimingVariants(patch)) {
const baseVariants: TimingVariants =
typeof base === 'string'
? { immediate: base, atPeriodEnd: base }
: { immediate: '', atPeriodEnd: '', ...(base as Partial<TimingVariants>) }
const result = { ...baseVariants }
if (patch.immediate) result.immediate = patch.immediate
if (patch.atPeriodEnd) result.atPeriodEnd = patch.atPeriodEnd
return result
// Leaves that accept an { immediate, atPeriodEnd } pair, by dot-path. The
// base value alone can't identify them — most default to a plain string —
// and pair patches must be rejected everywhere else: a pair landing on a
// plain leaf would reach the renderer as an object child and throw.
const TIMING_AWARE_PATHS: ReadonlySet<string> = new Set([
'confirm.cta',
'confirm.periodEndNotice',
'success.cancelled.title',
'success.cancelled.description',
])

// The base (catalog) drives the merge, not the patch: org overrides arrive
// unvalidated over the wire, so a patch shaped wrong for its slot is dropped
// rather than trusted, and keys the catalog doesn't declare are ignored —
// the catalog is closed. Empty-string overrides are also dropped (a blank
// dashboard field must not clobber a real string — same rule as the embed),
// which is why defaults may legitimately hold '' but patches never land one.
function mergeValue(base: unknown, patch: unknown, path: string): unknown {
if (typeof base === 'string' || isTimingVariants(base)) {
if (typeof patch === 'string') return patch === '' ? base : patch
if (isTimingVariants(patch) && TIMING_AWARE_PATHS.has(path)) {
const result: TimingVariants =
typeof base === 'string'
? { immediate: base, atPeriodEnd: base }
: { immediate: '', atPeriodEnd: '', ...(base as Partial<TimingVariants>) }
let applied = false
if (typeof patch.immediate === 'string' && patch.immediate) {
result.immediate = patch.immediate
applied = true
}
if (typeof patch.atPeriodEnd === 'string' && patch.atPeriodEnd) {
result.atPeriodEnd = patch.atPeriodEnd
applied = true
}
return applied ? result : base
}
return base
}
if (typeof patch === 'object' && patch !== null && typeof base === 'object' && base !== null) {
if (
typeof base === 'object' &&
base !== null &&
typeof patch === 'object' &&
patch !== null &&
!Array.isArray(patch) &&
!isTimingVariants(patch)
) {
const merged: Record<string, unknown> = { ...(base as Record<string, unknown>) }
for (const [key, value] of Object.entries(patch)) {
if (value == null) continue
merged[key] = key in merged ? mergeValue(merged[key], value) : value
if (value == null || !(key in merged)) continue
merged[key] = mergeValue(merged[key], value, path ? `${path}.${key}` : key)
}
return merged
}
Expand All @@ -222,7 +280,7 @@ function mergeValue(base: unknown, patch: unknown): unknown {

export function mergeMessages(base: CancelFlowMessages, patch: MessagesPatch | undefined): CancelFlowMessages {
if (!patch) return base
return mergeValue(base, patch) as CancelFlowMessages
return mergeValue(base, patch, '') as CancelFlowMessages
}

export function resolveLocale(explicit?: string): string {
Expand All @@ -243,23 +301,28 @@ function localeChain(locale: string): string[] {
return chain
}

/**
* Resolve the full catalog for a locale: built-in defaults, then `patches`
* in argument order (the org-level layer, once it exists, goes here), then
* the config's per-locale messages in fallback-chain order — so the
* developer's own overrides always end up on top.
*/
export function buildMessages(i18n?: I18nConfig, ...patches: Array<MessagesPatch | undefined>): CancelFlowMessages {
const locale = resolveLocale(i18n?.locale)
let resolved = defaultMessages
for (const patch of patches) {
resolved = mergeMessages(resolved, patch)
}
if (i18n?.messages) {
const byLowerKey = new Map(Object.entries(i18n.messages).map(([k, v]) => [k.toLowerCase(), v]))
for (const lang of localeChain(locale)) {
resolved = mergeMessages(resolved, byLowerKey.get(lang))
}
function applyLocaleLayer(
base: CancelFlowMessages,
byLang: Record<string, MessagesPatch> | undefined,
chain: string[],
): CancelFlowMessages {
if (!byLang) return base
const byLowerKey = new Map(Object.entries(byLang).map(([k, v]) => [k.toLowerCase(), v]))
let resolved = base
for (const lang of chain) {
resolved = mergeMessages(resolved, byLowerKey.get(lang))
}
return resolved
}

/**
* Resolve the full catalog for a locale. Layers, lowest to highest: built-in
* defaults, then `orgMessages` (dashboard-configured overrides delivered on
* the token-mode config), then the config's own per-locale messages — the
* developer's overrides always end up on top. Both per-language layers run
* through the same locale fallback chain.
*/
export function buildMessages(i18n?: I18nConfig, orgMessages?: Record<string, MessagesPatch>): CancelFlowMessages {
const chain = localeChain(resolveLocale(i18n?.locale))
return applyLocaleLayer(applyLocaleLayer(defaultMessages, orgMessages, chain), i18n?.messages, chain)
}
6 changes: 6 additions & 0 deletions packages/react/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,9 @@ export interface ConfirmStepProps {
export interface SuccessStepProps {
outcome: 'saved' | 'cancelled'
offer?: OfferDecision
/** The accepted offer (consumer-facing shape, incl. any accept result).
* Present when outcome is 'saved'. */
acceptedOffer?: AcceptedOffer
title: string
description?: string
customer: DirectCustomer | null
Expand Down Expand Up @@ -648,6 +651,9 @@ export interface FlowState {
followupResponse: string
feedback: string
outcome: 'saved' | 'cancelled' | null
/** The offer the customer accepted, set alongside outcome 'saved'. Drives
* per-offer success copy; `null` until then and for cancellations. */
acceptedOffer: AcceptedOffer | null
isProcessing: boolean
error: Error | null
customer: DirectCustomer | null
Expand Down
Loading
Loading