diff --git a/apps/playground/src/TestHarness.tsx b/apps/playground/src/TestHarness.tsx index 686a50b..108759e 100644 --- a/apps/playground/src/TestHarness.tsx +++ b/apps/playground/src/TestHarness.tsx @@ -8,6 +8,7 @@ import type { CustomStepProps, DirectCustomer, DirectSubscription, + I18nConfig, Mode, Step, } from '@churnkey/react/core' @@ -23,6 +24,7 @@ type Scenario = | 'all-offers' | 'standalone-offer' | 'color-scheme' + | 'i18n' const SCENARIOS: { id: Scenario; label: string; description: string }[] = [ { @@ -72,6 +74,12 @@ const SCENARIOS: { id: Scenario; label: string; description: string }[] = [ label: 'Color Scheme', description: 'Toggle light / dark / auto. `auto` follows OS preference.', }, + { + id: 'i18n', + label: 'i18n / Text Overrides', + description: + 'Message overrides via the i18n prop: chrome strings, timing-aware confirm/success copy, locale fallback chain. Declare the cancel timing to flip variants; leave it undeclared and pairs resolve to atPeriodEnd with the access notice hidden.', + }, ] // A customized success step on every flow catches regressions in the @@ -225,6 +233,51 @@ const allOffersSteps: Step[] = [ successStep, ] +// No confirmLabel / goBackLabel / success titles here — per-step props beat +// i18n messages, so setting them would mask exactly what this scenario +// exists to show. The feedback minLength exercises {minLength} interpolation. +const i18nSteps: Step[] = [ + { + type: 'survey', + reasons: [ + { id: 'expensive', label: 'Too expensive', offer: { type: 'discount', percentOff: 20, durationInMonths: 3 } }, + { id: 'missing', label: 'Missing features' }, + ], + }, + { type: 'feedback', required: true, minLength: 20 }, + { type: 'confirm' }, + { type: 'success' }, +] + +// 'en' shows targeted overrides incl. timing pairs; 'de' is a partial catalog +// (untranslated keys fall back to the en overrides, then defaults); 'de-AT' +// has no entry at all, proving the exact → base-language → en chain. +const I18N_MESSAGES: I18nConfig['messages'] = { + en: { + common: { continue: 'Keep going →', done: 'All set' }, + survey: { title: 'Mind telling us why?' }, + confirm: { + cta: { immediate: 'Cancel immediately', atPeriodEnd: 'Turn off auto-renew' }, + goBack: 'Actually, never mind', + }, + success: { + cancelled: { + title: { immediate: 'Subscription cancelled', atPeriodEnd: 'Auto-renew is off' }, + description: { + immediate: 'Your access has ended.', + atPeriodEnd: 'You keep access until the end of your billing period.', + }, + }, + }, + }, + de: { + common: { continue: 'Weiter', back: 'Zurück', done: 'Fertig', processing: 'Wird verarbeitet…' }, + survey: { title: 'Warum möchtest du kündigen?' }, + feedback: { title: 'Möchtest du uns noch etwas mitteilen?', placeholderWithMin: 'Mindestens {minLength} Zeichen…' }, + confirm: { title: 'Kündigung bestätigen', cta: 'Auto-Verlängerung deaktivieren', goBack: 'Doch nicht' }, + }, +} + // Flow starts directly on an offer step. Exercises buildInitialState's // currentStep.offer read for the first step and the offer-first entry path. const standaloneOfferSteps: Step[] = [ @@ -596,6 +649,8 @@ export function TestHarness() { const [mode, setMode] = useState('test') const [open, setOpen] = useState(false) const [colorScheme, setColorScheme] = useState<'light' | 'dark' | 'auto'>('light') + const [locale, setLocale] = useState<'en' | 'de' | 'de-AT'>('en') + const [localTiming, setLocalTiming] = useState<'period-end' | 'immediate' | 'undeclared'>('undeclared') const logRef = useRef(null) const [logs, setLogs] = useState([]) @@ -705,6 +760,8 @@ export function TestHarness() { return allOffersSteps case 'standalone-offer': return standaloneOfferSteps + case 'i18n': + return i18nSteps case 'token': case 'token-analytics': return undefined @@ -870,6 +927,36 @@ export function TestHarness() { )} + {/* Locale picker (i18n scenario only) */} + {scenario === 'i18n' && ( +
+ Locale +
+ {(['en', 'de', 'de-AT'] as const).map((l) => ( + setLocale(l)} /> + ))} +
+

+ de is a partial catalog — untranslated keys fall through to the en overrides, then + defaults. de-AT has no catalog entry and resolves via the base language. Switching locale + remounts the flow (the machine reads i18n once at mount). +

+
+ Cancel timing (local declaration) +
+
+ {(['undeclared', 'immediate', 'period-end'] as const).map((t) => ( + setLocalTiming(t)} /> + ))} +
+

+ Sets the cancelAtPeriodEnd prop. period-end shows the access-until notice on + confirm; undeclared resolves pairs to atPeriodEnd but keeps the notice hidden. In token mode + the server-resolved value wins. +

+
+ )} + {/* Launch */} )} @@ -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,22 @@ function StepRenderer({ } } +// 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` +// (undeclared 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 && (