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
92 changes: 92 additions & 0 deletions apps/playground/src/TestHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
CustomStepProps,
DirectCustomer,
DirectSubscription,
I18nConfig,
Mode,
Step,
} from '@churnkey/react/core'
Expand All @@ -23,6 +24,7 @@ type Scenario =
| 'all-offers'
| 'standalone-offer'
| 'color-scheme'
| 'i18n'

const SCENARIOS: { id: Scenario; label: string; description: string }[] = [
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -596,6 +649,8 @@ export function TestHarness() {
const [mode, setMode] = useState<Mode>('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<HTMLDivElement>(null)
const [logs, setLogs] = useState<string[]>([])

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -870,6 +927,36 @@ export function TestHarness() {
</fieldset>
)}

{/* Locale picker (i18n scenario only) */}
{scenario === 'i18n' && (
<fieldset style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: 16, marginBottom: 16 }}>
<legend style={{ fontSize: 13, fontWeight: 600, color: '#374151', padding: '0 4px' }}>Locale</legend>
<div style={{ display: 'flex', gap: 6 }}>
{(['en', 'de', 'de-AT'] as const).map((l) => (
<Pill key={l} label={l} selected={locale === l} onClick={() => setLocale(l)} />
))}
</div>
<p style={{ fontSize: 12, color: '#6b7280', margin: '8px 0 0' }}>
<code>de</code> is a partial catalog — untranslated keys fall through to the <code>en</code> overrides, then
defaults. <code>de-AT</code> has no catalog entry and resolves via the base language. Switching locale
remounts the flow (the machine reads <code>i18n</code> once at mount).
</p>
<div style={{ fontSize: 13, fontWeight: 600, color: '#374151', padding: '12px 0 4px' }}>
Cancel timing (local declaration)
</div>
<div style={{ display: 'flex', gap: 6 }}>
{(['undeclared', 'immediate', 'period-end'] as const).map((t) => (
<Pill key={t} label={t} selected={localTiming === t} onClick={() => setLocalTiming(t)} />
))}
</div>
<p style={{ fontSize: 12, color: '#6b7280', margin: '8px 0 0' }}>
Sets the <code>cancelAtPeriodEnd</code> prop. <code>period-end</code> shows the access-until notice on
confirm; <code>undeclared</code> resolves pairs to atPeriodEnd but keeps the notice hidden. In token mode
the server-resolved value wins.
</p>
</fieldset>
)}

{/* Launch */}
<button
type="button"
Expand Down Expand Up @@ -938,6 +1025,11 @@ export function TestHarness() {
{/* CancelFlow */}
{open && (
<CancelFlow
key={scenario === 'i18n' ? `${locale}-${localTiming}` : undefined}
i18n={scenario === 'i18n' ? { locale, messages: I18N_MESSAGES } : undefined}
cancelAtPeriodEnd={
scenario === 'i18n' && localTiming !== 'undeclared' ? localTiming === 'period-end' : undefined
}
appId={needsAppId && appId ? appId : undefined}
customer={customer}
subscriptions={subscriptions}
Expand Down
66 changes: 50 additions & 16 deletions packages/react/src/components/cancel-flow.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand All @@ -38,6 +40,7 @@ export function CancelFlow(props: CancelFlowProps) {
isLoading={isLoading}
loadError={loadError}
onRetry={retry}
messages={machine.messages}
/>
)
}
Expand All @@ -62,6 +65,7 @@ function LoadStatus({
isLoading,
loadError,
onRetry,
messages,
}: {
appearance?: CancelFlowProps['appearance']
classNames?: CancelFlowProps['classNames']
Expand All @@ -70,6 +74,7 @@ function LoadStatus({
isLoading: boolean
loadError: Error | null
onRetry: () => void
messages: CancelFlowMessages
}) {
const scheme = useColorScheme(appearance?.colorScheme)
const appearanceStyle = appearanceToStyle(appearance)
Expand All @@ -80,7 +85,7 @@ function LoadStatus({
return (
<div className="ck-cancel-flow" data-color-scheme={scheme} style={appearanceStyle}>
<Modal open={true} onClose={handleClose} className={classNames?.modal} overlayClassName={classNames?.overlay}>
<CloseButton onClose={handleClose} className={classNames?.closeButton} />
<CloseButton onClose={handleClose} className={classNames?.closeButton} label={messages.common.close} />
<div className="ck-content">
{isLoading && (
<div className="ck-loading" style={{ padding: '32px', textAlign: 'center' }}>
Expand All @@ -96,13 +101,13 @@ function LoadStatus({
margin: '0 auto 16px',
}}
/>
<p style={{ color: 'var(--ck-color-text-secondary, #6b7280)' }}>Loading your options...</p>
<p style={{ color: 'var(--ck-color-text-secondary, #6b7280)' }}>{messages.common.loading}</p>
</div>
)}
{loadError && (
<div className="ck-error" role="alert" style={{ padding: '32px', textAlign: 'center' }}>
<p className="ck-error-message" style={{ marginBottom: 16 }}>
We couldn't load your cancellation options. Please try again.
{messages.common.loadError}
</p>
<button
type="button"
Expand All @@ -119,7 +124,7 @@ function LoadStatus({
cursor: 'pointer',
}}
>
Try again
{messages.common.tryAgain}
</button>
</div>
)}
Expand All @@ -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
Expand All @@ -149,12 +155,14 @@ function FlowShell({ machine, state, appearance, classNames, components, customC
return (
<div className="ck-cancel-flow" data-color-scheme={scheme} style={appearanceStyle}>
<Modal open={true} onClose={machine.close} className={classNames?.modal} overlayClassName={classNames?.overlay}>
<CloseButton onClose={machine.close} className={classNames?.closeButton} />
<CloseButton onClose={machine.close} className={classNames?.closeButton} label={messages.common.close} />
<div className="ck-content">
{machine.canGoBack && <BackButton onBack={machine.back} className={classNames?.backButton} />}
{machine.canGoBack && (
<BackButton onBack={machine.back} className={classNames?.backButton} label={messages.common.back} />
)}
{state.error && (
<div className="ck-error" role="alert">
<p className="ck-error-message">Something went wrong. Please try again.</p>
<p className="ck-error-message">{messages.common.error}</p>
</div>
)}
<StepRenderer state={state} machine={machine} components={components} customComponents={customComponents} />
Expand All @@ -176,14 +184,15 @@ function StepRenderer({
customComponents?: CustomComponents
}) {
const stepConfig = machine.currentStep
const messages = machine.messages

switch (state.step) {
case 'survey': {
const Survey = components?.Survey ?? DefaultSurvey
const config = stepConfig as SurveyStep | undefined
return (
<Survey
title={config?.title ?? defaultTitles.survey}
title={config?.title ?? messages.survey.title}
description={config?.description}
customer={state.customer}
subscriptions={state.subscriptions}
Expand All @@ -195,6 +204,7 @@ function StepRenderer({
onNext={machine.next}
classNames={config?.classNames}
components={components}
messages={messages}
/>
)
}
Expand Down Expand Up @@ -234,6 +244,7 @@ function StepRenderer({
isProcessing={state.isProcessing}
classNames={config?.classNames}
components={components}
messages={messages}
/>
)
}
Expand All @@ -243,7 +254,7 @@ function StepRenderer({
const config = stepConfig as FeedbackStep | undefined
return (
<Feedback
title={config?.title ?? defaultTitles.feedback}
title={config?.title ?? messages.feedback.title}
description={config?.description}
customer={state.customer}
subscriptions={state.subscriptions}
Expand All @@ -254,6 +265,7 @@ function StepRenderer({
onChange={machine.setFeedback}
onSubmit={machine.next}
classNames={config?.classNames}
messages={messages}
/>
)
}
Expand All @@ -263,18 +275,20 @@ function StepRenderer({
const config = stepConfig as ConfirmStep | undefined
return (
<Confirm
title={config?.title ?? defaultTitles.confirm}
title={config?.title ?? messages.confirm.title}
description={config?.description}
customer={state.customer}
subscriptions={state.subscriptions}
losses={config?.losses}
lossesLabel={config?.lossesLabel}
confirmLabel={config?.confirmLabel ?? 'Cancel subscription'}
goBackLabel={config?.goBackLabel ?? 'Go back'}
confirmLabel={config?.confirmLabel ?? selectTiming(messages.confirm.cta, state.cancelAtPeriodEnd)}
goBackLabel={config?.goBackLabel ?? messages.confirm.goBack}
periodEndNotice={resolvePeriodEndNotice(state, messages)}
onConfirm={machine.cancel}
onGoBack={machine.back}
isProcessing={state.isProcessing}
classNames={config?.classNames}
messages={messages}
/>
)
}
Expand All @@ -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}
/>
)
}
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading
Loading