diff --git a/apps/playground/src/TestHarness.tsx b/apps/playground/src/TestHarness.tsx index 108759e..4e58875 100644 --- a/apps/playground/src/TestHarness.tsx +++ b/apps/playground/src/TestHarness.tsx @@ -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' }, ], }, diff --git a/packages/react/src/components/cancel-flow.tsx b/packages/react/src/components/cancel-flow.tsx index d0fee8f..265d8b5 100644 --- a/packages/react/src/components/cancel-flow.tsx +++ b/packages/react/src/components/cancel-flow.tsx @@ -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, @@ -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 ( /** Bandit key used for auto-optimization, if it ran. */ autoOptimizationKey?: string } diff --git a/packages/react/src/core/index.ts b/packages/react/src/core/index.ts index d955df8..c0f4bfa 100644 --- a/packages/react/src/core/index.ts +++ b/packages/react/src/core/index.ts @@ -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' diff --git a/packages/react/src/core/machine.ts b/packages/react/src/core/machine.ts index dde8d4b..db67690 100644 --- a/packages/react/src/core/machine.ts +++ b/packages/react/src/core/machine.ts @@ -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' @@ -222,6 +222,7 @@ export class CancelFlowMachine { private presentedOffers: PresentedOffer[] = [] private customStepResults: Record = {} private configMode: Mode = 'live' + private i18nConfig: I18nConfig | undefined private localCancelAtPeriodEnd: boolean | null = null private resolvedMessages: CancelFlowMessages private stepEnteredAt: number = Date.now() @@ -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 @@ -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 }) @@ -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 @@ -502,6 +510,7 @@ export class CancelFlowMachine { customer, subscriptions, cancelAtPeriodEnd: this.config?.settings.cancelAtPeriodEnd ?? this.localCancelAtPeriodEnd, + acceptedOffer: null, } } @@ -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 = { step: 'success', outcome, isProcessing: false } + const partial: Partial = { + step: 'success', + outcome, + isProcessing: false, + acceptedOffer: acceptedOffer ?? null, + } if (success) partial.currentStepId = success.guid this.setState(partial) } diff --git a/packages/react/src/core/messages.ts b/packages/react/src/core/messages.ts index ad1b74d..0a4e79a 100644 --- a/packages/react/src/core/messages.ts +++ b/packages/react/src/core/messages.ts @@ -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 @@ -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 @@ -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', @@ -194,26 +222,56 @@ export function formatMessage(template: string, vars: Record) } - 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 = 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) } + 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 = { ...(base as Record) } 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 } @@ -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 { @@ -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): 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 | 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): CancelFlowMessages { + const chain = localeChain(resolveLocale(i18n?.locale)) + return applyLocaleLayer(applyLocaleLayer(defaultMessages, orgMessages, chain), i18n?.messages, chain) +} diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index 86f8f4e..d4dd0bc 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -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 @@ -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 diff --git a/packages/react/tests/components/cancel-flow.test.tsx b/packages/react/tests/components/cancel-flow.test.tsx index befaaf4..3438dd1 100644 --- a/packages/react/tests/components/cancel-flow.test.tsx +++ b/packages/react/tests/components/cancel-flow.test.tsx @@ -102,8 +102,10 @@ describe('CancelFlow', () => { await user.click(screen.getByText('Continue')) await user.click(screen.getByText('Accept offer')) + // Built-in offer types get type-specific success copy (embed parity); + // the generic "Welcome back!" remains the fallback for other types. await waitFor(() => { - expect(screen.getByText('Welcome back!')).toBeInTheDocument() + expect(screen.getByText('Discount applied.')).toBeInTheDocument() }) expect(onAccept).toHaveBeenCalledWith( expect.objectContaining({ type: 'discount', percentOff: 20, durationInMonths: 3 }), @@ -582,3 +584,63 @@ describe('local-mode timing declaration', () => { expect(screen.getByText(/Your access continues until (June 30|July 1), 2026\./)).toBeInTheDocument() }) }) + +describe('per-offer success copy', () => { + const offerSteps: Step[] = [ + { + type: 'survey', + reasons: [ + { id: 'expensive', label: 'Too expensive', offer: { type: 'discount', percentOff: 20, durationInMonths: 3 } }, + { id: 'busy', label: 'Too busy', offer: { type: 'pause', months: 3 } }, + ], + }, + { type: 'confirm' }, + ] + + it('accepting a discount shows discount-specific copy', async () => { + const user = userEvent.setup() + renderFlow({ steps: offerSteps }) + await user.click(screen.getByText('Too expensive')) + await user.click(screen.getByText('Continue')) + await user.click(screen.getByText('Accept offer')) + expect(await screen.findByText('Discount applied.')).toBeInTheDocument() + expect(screen.getByText("We're so happy you're still here.")).toBeInTheDocument() + }) + + it('accepting a pause shows the resume date', async () => { + const user = userEvent.setup() + renderFlow({ steps: offerSteps }) + await user.click(screen.getByText('Too busy')) + await user.click(screen.getByText('Continue')) + // Pick the max pause length so the asserted date is deterministic. + await user.click(screen.getByText('3 months')) + await user.click(screen.getByText('Pause subscription')) + expect(await screen.findByText('Subscription paused.')).toBeInTheDocument() + const resume = new Date() + resume.setMonth(resume.getMonth() + 3) + const formatted = resume.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + expect(screen.getByText(`You won't be billed again until ${formatted}.`)).toBeInTheDocument() + }) + + it('per-offer copy loses to an explicit savedTitle', async () => { + const user = userEvent.setup() + renderFlow({ steps: [...offerSteps, { type: 'success', savedTitle: 'Custom saved title' }] }) + await user.click(screen.getByText('Too expensive')) + await user.click(screen.getByText('Continue')) + await user.click(screen.getByText('Accept offer')) + expect(await screen.findByText('Custom saved title')).toBeInTheDocument() + }) + + it('i18n can override one offer type without touching the others', async () => { + const user = userEvent.setup() + renderFlow({ + steps: offerSteps, + i18n: { messages: { en: { success: { saved: { discount: { title: 'Deal locked in!' } } } } } }, + }) + await user.click(screen.getByText('Too expensive')) + await user.click(screen.getByText('Continue')) + await user.click(screen.getByText('Accept offer')) + expect(await screen.findByText('Deal locked in!')).toBeInTheDocument() + expect(screen.getByText("We're so happy you're still here.")).toBeInTheDocument() + }) +}) diff --git a/packages/react/tests/core/machine.test.ts b/packages/react/tests/core/machine.test.ts index baf4cf2..546ab4b 100644 --- a/packages/react/tests/core/machine.test.ts +++ b/packages/react/tests/core/machine.test.ts @@ -1963,6 +1963,43 @@ describe('i18n / messages', () => { }) }) +describe('org-level translations (token mode)', () => { + function initWith(config: Partial, i18n?: import('../../src/core/messages').I18nConfig) { + const machine = new CancelFlowMachine(i18n ? { session: 'ck_placeholder', i18n } : { session: 'ck_placeholder' }) + machine.initializeFromConfig(sdkConfig(config), {} as any, { + appId: 'a', + customerId: 'c', + authHash: 'h', + mode: 'live' as const, + issuedAt: 0, + }) + return machine + } + + it('applies config.translations onto the defaults', () => { + const machine = initWith({ + translations: { en: { common: { continue: 'Org continue' }, confirm: { goBack: 'Org go back' } } }, + }) + expect(machine.messages.common.continue).toBe('Org continue') + expect(machine.messages.confirm.goBack).toBe('Org go back') + expect(machine.messages.common.back).toBe('Back') + }) + + it('developer i18n messages beat org translations', () => { + const machine = initWith( + { translations: { en: { common: { continue: 'Org continue', done: 'Org done' } } } }, + { locale: 'en', messages: { en: { common: { continue: 'Developer continue' } } } }, + ) + expect(machine.messages.common.continue).toBe('Developer continue') + expect(machine.messages.common.done).toBe('Org done') + }) + + it('leaves the pre-fetch resolution untouched when the config has no translations', () => { + const machine = initWith({}) + expect(machine.messages.common.continue).toBe('Continue') + }) +}) + describe('local-mode cancelAtPeriodEnd declaration', () => { it('threads the declared timing into state', () => { for (const value of [true, false]) { diff --git a/packages/react/tests/core/messages.test.ts b/packages/react/tests/core/messages.test.ts index 4fd3348..067104e 100644 --- a/packages/react/tests/core/messages.test.ts +++ b/packages/react/tests/core/messages.test.ts @@ -129,13 +129,79 @@ describe('resolveLocale / buildMessages', () => { expect(resolved.common.continue).toBe('PT-BR') }) - it('layers extra patches below the developer messages', () => { - // The patches argument is where the org-level layer will slot in. + it('layers org messages below the developer messages', () => { const resolved = buildMessages( { locale: 'en', messages: { en: { common: { continue: 'Developer' } } } }, - { common: { continue: 'Org', back: 'Org back' } }, + { en: { common: { continue: 'Org', back: 'Org back' } } }, ) expect(resolved.common.continue).toBe('Developer') expect(resolved.common.back).toBe('Org back') }) + + it('runs the org layer through the same locale fallback chain', () => { + const resolved = buildMessages( + { locale: 'de-AT' }, + { + en: { common: { continue: 'Org EN continue', done: 'Org EN done' } }, + de: { common: { continue: 'Org DE continue' } }, + }, + ) + expect(resolved.common.continue).toBe('Org DE continue') + expect(resolved.common.done).toBe('Org EN done') + expect(resolved.common.back).toBe('Back') + }) + + it('org timing-aware overrides survive under unrelated developer overrides', () => { + const resolved = buildMessages( + { locale: 'en', messages: { en: { common: { continue: 'Developer' } } } }, + { en: { confirm: { cta: { immediate: 'Cancel', atPeriodEnd: 'Turn off auto-renew' } } } }, + ) + expect(resolved.confirm.cta).toEqual({ immediate: 'Cancel', atPeriodEnd: 'Turn off auto-renew' }) + expect(resolved.common.continue).toBe('Developer') + }) +}) + +// The org layer arrives unvalidated over the wire, so the merge must treat +// the catalog as the schema: wrong-shaped patches are dropped, never trusted. +describe('malformed patches (base-driven dispatch)', () => { + it('ignores a string landing on a category node', () => { + const merged = mergeMessages(defaultMessages, { common: 'hello' } as never) + expect(merged.common.continue).toBe('Continue') + expect(typeof merged.common).toBe('object') + }) + + it('ignores a timing pair landing on a plain leaf', () => { + const merged = mergeMessages(defaultMessages, { + common: { continue: { atPeriodEnd: 'x' } }, + } as never) + expect(merged.common.continue).toBe('Continue') + }) + + it('ignores a timing pair landing on a category node', () => { + const merged = mergeMessages(defaultMessages, { + confirm: { immediate: 'x', atPeriodEnd: 'y' }, + } as never) + expect(merged.confirm.title).toBe('Confirm cancellation') + expect('immediate' in merged.confirm).toBe(false) + }) + + it('drops keys the catalog does not declare', () => { + const merged = mergeMessages(defaultMessages, { common: { madeUp: 'x' } } as never) + expect('madeUp' in merged.common).toBe(false) + }) + + it('ignores non-string variant values in a pair', () => { + const merged = mergeMessages(defaultMessages, { + confirm: { cta: { atPeriodEnd: 42 } }, + } as never) + expect(merged.confirm.cta).toBe('Cancel subscription') + }) + + it('ignores arrays and numbers on leaves', () => { + const merged = mergeMessages(defaultMessages, { + common: { continue: 42, back: ['x'] }, + } as never) + expect(merged.common.continue).toBe('Continue') + expect(merged.common.back).toBe('Back') + }) })