From 297f8f91361793350d2533d5c079fca8602b4c7e Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Fri, 10 Jul 2026 12:31:43 -0400 Subject: [PATCH 1/3] Merge org-level translations into message resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token-mode config now carries an optional translations field — dashboard-configured overrides keyed by language. initializeFromConfig re-resolves the catalog with that layer between the built-in defaults and the developer's i18n.messages, so org copy applies automatically while code-level overrides still win. Both per-language layers run through the same locale fallback chain. --- packages/react/src/core/api-types.ts | 8 +++++ packages/react/src/core/machine.ts | 10 +++++- packages/react/src/core/messages.ts | 39 ++++++++++++---------- packages/react/tests/core/machine.test.ts | 37 ++++++++++++++++++++ packages/react/tests/core/messages.test.ts | 27 +++++++++++++-- 5 files changed, 100 insertions(+), 21 deletions(-) diff --git a/packages/react/src/core/api-types.ts b/packages/react/src/core/api-types.ts index 919d1fc..7388687 100644 --- a/packages/react/src/core/api-types.ts +++ b/packages/react/src/core/api-types.ts @@ -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 { @@ -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 /** Bandit key used for auto-optimization, if it ran. */ autoOptimizationKey?: string } diff --git a/packages/react/src/core/machine.ts b/packages/react/src/core/machine.ts index 2127b93..7b17d73 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 resolvedMessages: CancelFlowMessages private stepEnteredAt: number = Date.now() private aborted = false @@ -234,6 +235,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) if (config.mode) this.configMode = config.mode if (config.customerAttributes) this.customerAttributes = config.customerAttributes @@ -451,6 +453,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 diff --git a/packages/react/src/core/messages.ts b/packages/react/src/core/messages.ts index 631a07b..145b20f 100644 --- a/packages/react/src/core/messages.ts +++ b/packages/react/src/core/messages.ts @@ -239,23 +239,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/tests/core/machine.test.ts b/packages/react/tests/core/machine.test.ts index 5cdd093..ea7252f 100644 --- a/packages/react/tests/core/machine.test.ts +++ b/packages/react/tests/core/machine.test.ts @@ -1962,3 +1962,40 @@ 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') + }) +}) diff --git a/packages/react/tests/core/messages.test.ts b/packages/react/tests/core/messages.test.ts index 4fd3348..ff74b73 100644 --- a/packages/react/tests/core/messages.test.ts +++ b/packages/react/tests/core/messages.test.ts @@ -129,13 +129,34 @@ 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') + }) }) From 53b99c7b04282c2d06a91cc7fa226b87d571d1e8 Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Fri, 10 Jul 2026 21:56:51 -0400 Subject: [PATCH 2/3] Drive the message merge from the catalog, not the patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (critical): mergeValue dispatched on the patch's shape, so malformed org overrides could corrupt the catalog's structure — a bare string landing on a category node replaced the whole node, and an { immediate, atPeriodEnd } object landing on a plain leaf reached the renderer as an object child and threw. The catalog is the schema now: patches shaped wrong for their slot are dropped, pair patches are only accepted on the four timing-aware paths, variant values must be non-empty strings, and keys the catalog doesn't declare are ignored. --- packages/react/src/core/messages.ts | 66 ++++++++++++++++------ packages/react/tests/core/messages.test.ts | 45 +++++++++++++++ 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/packages/react/src/core/messages.ts b/packages/react/src/core/messages.ts index 7576976..c7a8171 100644 --- a/packages/react/src/core/messages.ts +++ b/packages/react/src/core/messages.ts @@ -194,26 +194,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 +252,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 { diff --git a/packages/react/tests/core/messages.test.ts b/packages/react/tests/core/messages.test.ts index ff74b73..067104e 100644 --- a/packages/react/tests/core/messages.test.ts +++ b/packages/react/tests/core/messages.test.ts @@ -160,3 +160,48 @@ describe('resolveLocale / buildMessages', () => { 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') + }) +}) From 5595caf448db241937011c7a8a74f289daba5cd0 Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Mon, 13 Jul 2026 12:14:44 -0400 Subject: [PATCH 3/3] Add per-offer success copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting a pause and accepting a discount previously rendered the identical success screen; the embed has always confirmed each outcome specifically (discountApplied, pauseApplied, …). The saved catalog now carries per-offer entries with the embed's copy as defaults — discount, pause, trial_extension, plan_change, rebate — resolving as savedTitle prop, then the per-offer entry, then the generic saved copy. Contact, redirect, and custom types keep the generic fallback. Pause gets a resumeDescription with a {resumeDate} token, used when the accept result carries the selected duration (the built-in pause UI always passes it). The accepted offer is snapshotted into FlowState at the success transition because currentOffer derives from currentStepId, which a declared success step moves off the offer; Success components also receive it as a new optional acceptedOffer prop. --- apps/playground/src/TestHarness.tsx | 2 + packages/react/src/components/cancel-flow.tsx | 40 ++++++++++-- packages/react/src/core/index.ts | 9 ++- packages/react/src/core/machine.ts | 15 ++++- packages/react/src/core/messages.ts | 28 ++++++++ packages/react/src/core/types.ts | 6 ++ .../tests/components/cancel-flow.test.tsx | 64 ++++++++++++++++++- 7 files changed, 155 insertions(+), 9 deletions(-) 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 ( = { 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 c7a8171..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', 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() + }) +})