Skip to content

Add i18n: typed message catalog and timing-aware copy#34

Merged
robert-moore merged 4 commits into
mainfrom
rob/sdk-i18n
Jul 10, 2026
Merged

Add i18n: typed message catalog and timing-aware copy#34
robert-moore merged 4 commits into
mainfrom
rob/sdk-i18n

Conversation

@robert-moore

Copy link
Copy Markdown
Contributor

Adds text-override and localization support to @churnkey/react. Driven by a customer who needs different cancel-button and confirmation copy depending on whether the flow cancels immediately ("Cancel") or at period end ("Turn off auto-renew").

Design docs:

What

  • core/messages.ts: typed CancelFlowMessages catalog covering every chrome string (~40 keys). Flow content (step titles, offer copy) stays blueprint-authored and is deliberately not in it.
  • New i18n prop on CancelFlow / useCancelFlow: { locale?, messages? } with deep-partial per-locale overrides, {token} interpolation, and an exact → base-language → en fallback chain.
  • Timing-aware values: confirm.cta, confirm.periodEndNotice, and success.cancelled.* accept string | { immediate, atPeriodEnd }, resolved against the server's cancelAtPeriodEnd, which is now threaded into FlowState.
  • All hardcoded literals in the default components replaced with catalog lookups; defaults unchanged verbatim.
  • Headless: useCancelFlow returns messages and cancelAtPeriodEnd; selectTiming is exported.

Why the period-end notice is token-mode only

The docs described an "access continues until X" notice that the code didn't render — it was removed deliberately because the SDK couldn't know the flow's real timing and could promise access the customer wouldn't get. Token mode now knows the resolved timing, so the notice renders when it's definitively period-end and never in local mode. The pinned test documents this.

Compatibility

No breaking changes. New props are optional, default components fall back to defaultMessages when rendered standalone, and every default string is byte-identical. Precedence: defaults < i18n.messages < per-step props (confirmLabel etc. still win).

Pairs with a churnkey-api change that clamps settings.cancelAtPeriodEnd for legacy providers (Braintree cancels immediately regardless of the flag) so timing-conditional copy can't lie, and an sdk-docs PR adding the Text & localization page.

Testing

198 tests (26 new): merge/precedence/timing/interpolation units, machine state threading, and token-mode component tests that stub the config fetch and assert the rendered variant flips on the server boolean. tsc, biome, and the package build are clean.

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.
Locale picker (en / de / de-AT) exercising the fallback chain — de is a
deliberately partial catalog, de-AT has no entry and resolves via the
base language. The en catalog uses timing-aware pairs on confirm.cta and
success.cancelled.*; local mode has unknown timing so the atPeriodEnd
variants render and the access notice stays hidden, which is the
designed behavior. Steps omit per-step label props since those outrank
i18n messages and would mask the overrides.
Review follow-up: timing-aware messages silently resolved to the
atPeriodEnd variant in local mode, where the SDK has no way to know the
real timing — an immediate-cancel integration could show "Turn off
auto-renew" copy. New optional cancelAtPeriodEnd on FlowConfig /
CancelFlowProps lets the developer declare their billing behavior;
token mode's server-resolved value stays authoritative.

An explicit declaration also satisfies the known-timing gate on the
confirm step's access-until notice, so local integrations that declare
period-end get it too. The playground i18n scenario grows a timing
picker exercising all three states.
@robert-moore

Copy link
Copy Markdown
Contributor Author

Review follow-up pushed: local mode can now declare its billing behavior via a new optional cancelAtPeriodEnd prop instead of timing-aware pairs silently resolving to the atPeriodEnd variant. The declaration drives variant selection and (when true) satisfies the known-timing gate on the access-until notice; token mode's server-resolved value stays authoritative. Undeclared local mode keeps the previous documented behavior. Playground i18n scenario gained a timing picker. 208 tests passing. Merged forward into #35.

@PavelNikoltsev
PavelNikoltsev self-requested a review July 10, 2026 19:08

@PavelNikoltsev PavelNikoltsev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Rob, approved and found two follow-ups, please check it out:

  1. Token mode isn't actually authoritative when the server omits the value. The state fallback uses ?? this.localCancelAtPeriodEnd (
    customer,
    subscriptions,
    cancelAtPeriodEnd: this.config?.settings.cancelAtPeriodEnd ?? this.localCancelAtPeriodEnd,
    }
    }
    ), but the real cancel call falls back to ?? true (
    await this.apiClient!.cancelSubscription(
    this.config?.settings.cancelAtPeriodEnd ?? true,
    this.blueprintId ?? undefined,
    )
    ). When settings.cancelAtPeriodEnd is absent (it's optional in the schema), the displayed timing and the server action can diverge, and the local declaration leaks into token mode — contradicting the JSDoc "In token mode ... this is ignored" (
    * (`false`)? Drives timing-aware messages and the confirm step's
    * access-until notice. In token mode the server-resolved value is
    * authoritative and this is ignored.
    */
    ). I suggest aligning both fallbacks and gating the local value on non-token mode.
  2. periodEndNotice can't be suppressed via override despite the JSDoc. The doc says "An empty variant suppresses the notice" (
    placeholderWithMin: string
    }
    confirm: {
    title: string
    ), but mergeValue drops empty strings (patch === '' ? base : patch and the truthy checks on the timing pair) (
    // Empty-string overrides are 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): unknown {
    if (typeof patch === 'string') return patch === '' ? base : patch
    if (isTimingVariants(patch)) {
    const baseVariants: TimingVariants =
    typeof base === 'string'
    ? { immediate: base, atPeriodEnd: base }
    : { immediate: '', atPeriodEnd: '', ...(base as Partial<TimingVariants>) }
    const result = { ...baseVariants }
    ), so periodEndNotice: '' / { atPeriodEnd: '' } renders the default instead of suppressing. Either honor empty overrides for this key or drop the "empty suppresses" claim from the doc.

Review follow-ups. The local cancelAtPeriodEnd declaration leaked into
token mode when the server omitted settings.cancelAtPeriodEnd, so the
displayed timing could diverge from the cancel action's own
end-of-period fallback — and contradicted the prop's documented
token-mode behavior. The declaration is now stored only in local mode;
an absent server value leaves timing unknown, whose copy default
matches the action's fallback.

Also corrects the periodEndNotice doc: empty variants only suppress via
the DEFAULTS (that's how immediate timing hides the notice) — empty
override values are dropped like any blank override, so suppression
goes through the Confirm component or classNames instead.
@robert-moore

Copy link
Copy Markdown
Contributor Author

Both follow-ups addressed (cde7e8f, merged forward into #35):

  1. Token-mode leak: agreed, and your read of the divergence was exactly right — display fell back to the local declaration while the cancel action fell back to true. The declaration is now stored only when there's no session, so token mode with an absent server value resolves to unknown, whose copy default (atPeriodEnd) matches the action's fallback by construction. Tests cover both the omitted-server-value case and the pre-fetch window.

  2. periodEndNotice suppression claim: you're right that the doc promised something the merge deliberately prevents. I kept the merge rule (blank overrides must never clobber — it protects against empty dashboard fields once org-level overrides land in Merge org-level translations into message resolution #35/#948) and fixed the docs instead: empty variants only suppress via the defaults (that's how immediate timing hides the notice), and full suppression goes through the Confirm component or classNames.periodEndNotice. Corrected in the JSDoc and on sdk.churnkey.co#8.

@robert-moore
robert-moore merged commit 3bdb757 into main Jul 10, 2026
1 check passed
robert-moore added a commit that referenced this pull request Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants