From 055026ec2b53539eb8d86fd7753430f3f33cab16 Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Fri, 10 Jul 2026 11:36:54 -0400 Subject: [PATCH 1/4] Add i18n: typed message catalog, i18n prop, timing-aware copy Every string the SDK renders itself now flows through a typed CancelFlowMessages catalog instead of hardcoded literals. The new i18n prop takes a locale plus deep-partial per-locale overrides; lookup runs an exact -> base-language -> en fallback chain and empty-string overrides are dropped so a blank field can't clobber a real string. Timing-sensitive messages (confirm CTA, period-end notice, cancelled success copy) accept { immediate, atPeriodEnd } pairs resolved against the server's cancelAtPeriodEnd, which is now threaded from the token config into FlowState. The period-end notice renders only when timing is known to be period-end: an earlier hardcoded notice was removed because it could contradict the merchant's setting, and that still holds in local mode where the SDK can't verify billing behavior. All new props are optional and default strings are unchanged, so existing integrations render identically. Headless consumers get messages and cancelAtPeriodEnd from useCancelFlow, plus an exported selectTiming helper. --- packages/react/src/components/cancel-flow.tsx | 65 +++-- .../src/components/steps/default-confirm.tsx | 10 +- .../src/components/steps/default-feedback.tsx | 8 +- .../src/components/steps/default-success.tsx | 6 +- .../src/components/steps/default-survey.tsx | 9 +- .../steps/offer/default-contact-offer.tsx | 5 +- .../steps/offer/default-discount-offer.tsx | 7 +- .../steps/offer/default-pause-offer.tsx | 9 +- .../steps/offer/default-plan-change-offer.tsx | 9 +- .../steps/offer/default-rebate-offer.tsx | 16 +- .../offer/default-trial-extension-offer.tsx | 9 +- .../structural/default-back-button.tsx | 4 +- .../structural/default-close-button.tsx | 4 +- packages/react/src/core/index.ts | 2 + packages/react/src/core/machine.ts | 13 + packages/react/src/core/messages.ts | 261 ++++++++++++++++++ packages/react/src/core/types.ts | 37 +++ packages/react/src/core/utils.ts | 11 +- .../react/src/headless/use-cancel-flow.ts | 1 + .../tests/components/cancel-flow.test.tsx | 94 ++++++- packages/react/tests/core/machine.test.ts | 49 ++++ packages/react/tests/core/messages.test.ts | 141 ++++++++++ 22 files changed, 716 insertions(+), 54 deletions(-) create mode 100644 packages/react/src/core/messages.ts create mode 100644 packages/react/tests/core/messages.test.ts diff --git a/packages/react/src/components/cancel-flow.tsx b/packages/react/src/components/cancel-flow.tsx index 5a563f7..8bd1fd6 100644 --- a/packages/react/src/components/cancel-flow.tsx +++ b/packages/react/src/components/cancel-flow.tsx @@ -1,5 +1,7 @@ import { type ReactElement, useEffect } from 'react' +import { formatPeriodEnd } from '../core/format' import type { CancelFlowMachine } from '../core/machine' +import { type CancelFlowMessages, formatMessage, selectTiming } from '../core/messages' import type { CancelFlowProps, ComponentOverrides, @@ -13,7 +15,7 @@ import type { SuccessStep, SurveyStep, } from '../core/types' -import { appearanceToStyle, BUILT_IN_OFFER_TYPES, defaultTitles } from '../core/utils' +import { appearanceToStyle, BUILT_IN_OFFER_TYPES } from '../core/utils' import { useCancelFlowMachine } from '../headless/use-cancel-flow-machine' import { DefaultConfirm } from './steps/default-confirm' import { DefaultFeedback } from './steps/default-feedback' @@ -38,6 +40,7 @@ export function CancelFlow(props: CancelFlowProps) { isLoading={isLoading} loadError={loadError} onRetry={retry} + messages={machine.messages} /> ) } @@ -62,6 +65,7 @@ function LoadStatus({ isLoading, loadError, onRetry, + messages, }: { appearance?: CancelFlowProps['appearance'] classNames?: CancelFlowProps['classNames'] @@ -70,6 +74,7 @@ function LoadStatus({ isLoading: boolean loadError: Error | null onRetry: () => void + messages: CancelFlowMessages }) { const scheme = useColorScheme(appearance?.colorScheme) const appearanceStyle = appearanceToStyle(appearance) @@ -80,7 +85,7 @@ function LoadStatus({ return (
- +
{isLoading && (
@@ -96,13 +101,13 @@ function LoadStatus({ margin: '0 auto 16px', }} /> -

Loading your options...

+

{messages.common.loading}

)} {loadError && (

- We couldn't load your cancellation options. Please try again. + {messages.common.loadError}

)} @@ -141,6 +146,7 @@ interface FlowShellProps { function FlowShell({ machine, state, appearance, classNames, components, customComponents }: FlowShellProps) { const scheme = useColorScheme(appearance?.colorScheme) const appearanceStyle = appearanceToStyle(appearance) + const messages = machine.messages const Modal = components?.Modal ?? DefaultModal const CloseButton = components?.CloseButton ?? DefaultCloseButton @@ -149,12 +155,14 @@ function FlowShell({ machine, state, appearance, classNames, components, customC return (
- +
- {machine.canGoBack && } + {machine.canGoBack && ( + + )} {state.error && (
-

Something went wrong. Please try again.

+

{messages.common.error}

)} @@ -176,6 +184,7 @@ function StepRenderer({ customComponents?: CustomComponents }) { const stepConfig = machine.currentStep + const messages = machine.messages switch (state.step) { case 'survey': { @@ -183,7 +192,7 @@ function StepRenderer({ const config = stepConfig as SurveyStep | undefined return ( ) } @@ -234,6 +244,7 @@ function StepRenderer({ isProcessing={state.isProcessing} classNames={config?.classNames} components={components} + messages={messages} /> ) } @@ -243,7 +254,7 @@ function StepRenderer({ const config = stepConfig as FeedbackStep | undefined return ( ) } @@ -263,18 +275,20 @@ function StepRenderer({ const config = stepConfig as ConfirmStep | undefined return ( ) } @@ -288,17 +302,21 @@ function StepRenderer({ outcome={state.outcome ?? 'cancelled'} offer={machine.currentOffer ?? undefined} title={ - isSaved ? (config?.savedTitle ?? 'Welcome back!') : (config?.cancelledTitle ?? 'Subscription cancelled') + isSaved + ? (config?.savedTitle ?? messages.success.saved.title) + : (config?.cancelledTitle ?? selectTiming(messages.success.cancelled.title, state.cancelAtPeriodEnd)) } description={ isSaved - ? (config?.savedDescription ?? 'Your offer has been applied.') - : (config?.cancelledDescription ?? "We're sorry to see you go.") + ? (config?.savedDescription ?? messages.success.saved.description) + : (config?.cancelledDescription ?? + selectTiming(messages.success.cancelled.description, state.cancelAtPeriodEnd)) } customer={state.customer} subscriptions={state.subscriptions} onClose={machine.close} classNames={config?.classNames} + messages={messages} /> ) } @@ -330,6 +348,21 @@ function StepRenderer({ } } +// The notice makes a factual claim about billing behavior, so it requires the +// timing to be KNOWN to be period-end — the server-resolved value in token +// mode. `null` (local mode) stays silent: an earlier hardcoded version of this +// notice was removed precisely because it could contradict the merchant's +// actual setting. Also empty when the period end can't be determined or the +// resolved message is blank. +function resolvePeriodEndNotice(state: FlowState, messages: CancelFlowMessages): string | undefined { + if (state.cancelAtPeriodEnd !== true) return undefined + const template = selectTiming(messages.confirm.periodEndNotice, state.cancelAtPeriodEnd) + if (!template) return undefined + const periodEnd = formatPeriodEnd(state.subscriptions) + if (!periodEnd) return undefined + return formatMessage(template, { periodEnd }) +} + // Skip runs in an effect so we don't mutate machine state during render. function UnregisteredStepFallback({ step, onSkip }: { step: string; onSkip: () => void }) { useEffect(() => { diff --git a/packages/react/src/components/steps/default-confirm.tsx b/packages/react/src/components/steps/default-confirm.tsx index 4daeae0..05a858f 100644 --- a/packages/react/src/components/steps/default-confirm.tsx +++ b/packages/react/src/components/steps/default-confirm.tsx @@ -1,3 +1,4 @@ +import { defaultMessages } from '../../core/messages' import type { ConfirmStepProps } from '../../core/types' import { cn } from '../../core/utils' import { RichText } from '../rich-text' @@ -9,11 +10,14 @@ export function DefaultConfirm({ lossesLabel, confirmLabel, goBackLabel, + periodEndNotice, onConfirm, onGoBack, isProcessing, classNames, + messages, }: ConfirmStepProps) { + const m = messages ?? defaultMessages const hasLosses = Array.isArray(losses) && losses.length > 0 return (
@@ -24,7 +28,7 @@ export function DefaultConfirm({ {hasLosses && (
-
{lossesLabel ?? "You'll lose access to:"}
+
{lossesLabel ?? m.confirm.lossesLabel}
    {losses.map((item) => (
  • @@ -38,13 +42,15 @@ export function DefaultConfirm({
)} + {periodEndNotice &&

{periodEndNotice}

} +
) diff --git a/packages/react/src/components/steps/default-success.tsx b/packages/react/src/components/steps/default-success.tsx index df27fe1..aed8dd6 100644 --- a/packages/react/src/components/steps/default-success.tsx +++ b/packages/react/src/components/steps/default-success.tsx @@ -1,9 +1,11 @@ +import { defaultMessages } from '../../core/messages' import type { SuccessStepProps } from '../../core/types' import { cn } from '../../core/utils' import { RichText } from '../rich-text' import { Checkmark } from './shared' -export function DefaultSuccess({ title, description, onClose, classNames }: SuccessStepProps) { +export function DefaultSuccess({ title, description, onClose, classNames, messages }: SuccessStepProps) { + const m = messages ?? defaultMessages return (
@@ -17,7 +19,7 @@ export function DefaultSuccess({ title, description, onClose, classNames }: Succ
diff --git a/packages/react/src/components/steps/default-survey.tsx b/packages/react/src/components/steps/default-survey.tsx index 7888256..bd1c921 100644 --- a/packages/react/src/components/steps/default-survey.tsx +++ b/packages/react/src/components/steps/default-survey.tsx @@ -1,3 +1,4 @@ +import { defaultMessages } from '../../core/messages' import type { ReasonButtonProps, SurveyStepProps } from '../../core/types' import { cn } from '../../core/utils' import { RichText } from '../rich-text' @@ -33,7 +34,9 @@ export function DefaultSurvey({ onNext, classNames, components, + messages, }: SurveyStepProps) { + const m = messages ?? defaultMessages const ReasonButton = components?.ReasonButton ?? DefaultReasonButton const selected = reasons.find((r) => r.id === selectedReason) const showFollowup = selected?.freeform === true @@ -60,11 +63,11 @@ export function DefaultSurvey({ {showFollowup && (