Skip to content

Merge org-level translations into message resolution#35

Merged
robert-moore merged 6 commits into
mainfrom
rob/sdk-i18n-org-overrides
Jul 13, 2026
Merged

Merge org-level translations into message resolution#35
robert-moore merged 6 commits into
mainfrom
rob/sdk-i18n-org-overrides

Conversation

@robert-moore

@robert-moore robert-moore commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Phase 2 of SDK i18n. #34 has merged; this now targets main directly.

Design docs:

What

The token-mode config payload gains an optional translations field — dashboard-configured message overrides keyed by language (stored on the new org.sdkTranslations, delivered by churnkey-api#948). initializeFromConfig re-resolves the catalog with that layer in the middle:

built-in defaults < org translations < developer i18n.messages < per-step props

Both per-language layers run through the same locale fallback chain (exact tag → base language → en). Local mode is unaffected — no org fetch, no layer, per the design decision.

Testing

New units for the org layer (below developer messages, locale chain, timing-pair values) and machine tests for config.translations application. 203 tests passing, tsc + biome clean.

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.
# Conflicts:
#	packages/react/src/core/machine.ts
#	packages/react/tests/core/machine.test.ts
@robert-moore
robert-moore changed the base branch from rob/sdk-i18n to main July 10, 2026 20:39
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.
@robert-moore

Copy link
Copy Markdown
Contributor Author

Review follow-up: the merge now dispatches on the catalog's shape instead of the patch's. Previously a bare string landing on a category node replaced the node, and an { immediate, atPeriodEnd } object landing on a plain leaf reached the renderer as an object child (React throws on object children). The catalog is the schema now: wrong-shaped patches are dropped, pair patches are only accepted on the four timing-aware paths, variant values must be non-empty strings, and undeclared keys are ignored. Six malformed-shape tests added; 216 passing.

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.
@robert-moore

Copy link
Copy Markdown
Contributor Author

Added per-offer success copy per Rob's request: success.saved gains entries for discount / pause / trial_extension / plan_change / rebate with the embed's *Applied copy as defaults (the two surfaces now confirm outcomes with the same words), pause supports {resumeDate} from the accept result, and the accepted offer is snapshotted into FlowState.acceptedOffer at the success transition — currentOffer derives from currentStepId, which a declared success step moves off the offer, so it was unreliable exactly where this needs it. Resolution: savedTitle props → per-offer entry → generic. 220 tests. Docs: sdk.churnkey.co#9.

@hookdump hookdump 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.

LGTM ✅

@robert-moore
robert-moore merged commit 64424a6 into main Jul 13, 2026
1 check passed
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