Skip to content

Add prebuilt custom-offer recipes to @churnkey/react#41

Merged
PavelNikoltsev merged 3 commits into
mainfrom
feat/react-custom-offer-recipes
Jul 22, 2026
Merged

Add prebuilt custom-offer recipes to @churnkey/react#41
PavelNikoltsev merged 3 commits into
mainfrom
feat/react-custom-offer-recipes

Conversation

@PavelNikoltsev

Copy link
Copy Markdown
Contributor

Summary

Adds a prebuilt tier of custom-offer components behind a new @churnkey/react/recipes subpath export, so merchants who own the billing action but don't want to build or maintain offer UI (the Motley Fool ask) get a one-import integration:

  • TermExtensionOffer — merchant-executed "delay your next charge by N days". Reads offer.data.days, shows the extension badge and the computed new renewal date (current period end + N, hidden when no period end is available), renders the flow-authored copy, and passes { days } through onAccept.
  • PassthroughOffer — copy + accept/decline for offer types whose substance lives entirely in the builder copy (scheduled/immediate transfer). Passes offer.data through as the result; one component covers any number of such types.

Recipes reuse the built-in ck-* styling system, so they inherit theming, appearance variables, and dark mode wholesale.

Two SDK bugs surfaced during end-to-end testing and are fixed here:

  1. machine: customer/subscriptions props were stored only when appId was set, so pure local-mode integrations never saw subscription data (recipes couldn't compute the renewal date, Confirm couldn't show the period-end notice).
  2. transform: survey-attached offers lost their server-resolved copy and decisionId in token mode — flows rendered synthesized default copy ("Before you go...") instead of the dashboard's, for every offer type, and the presented/accepted analytics join on decisionId was broken.

Supporting changes: ReasonConfig.offer widened to OfferConfig | OfferDecision (the runtime already handled decisions via toDecision); the copy-paste templates moved from packages/react/recipes/ to packages/react/examples/ to free the recipes name (they were repo-only: unpublished and unreferenced); playground gained a "5b. Prebuilt Recipes" demo section, recipe wiring + customerAttributes in the token-mode section, and a @churnkey/react/recipes vite alias.

Notes

  • Recipes read chrome strings ("Processing…", the end-date label) from defaultMessages — integrator i18n overrides don't reach them because CustomOfferProps doesn't carry messages. Acceptable for v1; fixable later by extending CustomOfferProps.
  • The registry/builder side is ready for these offers (CustomOfferType registry, builder dropdown + schema form), but registry entries can only be created via dashboard-authenticated API calls today — no self-serve UI. Worth a separate ticket before the TMF rollout.
  • Per the source doc's recommendation this is option 1 ("recipes now, aim at template offers"); the term-extension component is deliberately generic so it can graduate to the default UI of a future first-class tier.

Test plan

  • vitest: 235 passing (10 new recipe tests, 1 new transform regression test)
  • tsc clean in packages/react and apps/playground; biome clean
  • Build: dist/recipes.* emitted, recipes absent from the main bundle
  • Local-mode e2e (playground 5b): badge, computed date, theming, onAccept({ days }) / data passthrough payloads
  • Token-mode e2e against a local API + real builder flow: segment match via customerAttributes, builder copy and data.days reach the recipe, date computed from the live Stripe period end, accept payload { type, data, result } correct; passthrough pipeline verified with a registered showcase offer type

Ship TermExtensionOffer and PassthroughOffer behind a new
@churnkey/react/recipes subpath export so merchants can render
merchant-executed custom offers (term extension, transfers) without
building the UI themselves. Recipes reuse the built-in offer styling
and pass their result through onAccept.

Also fixes two SDK bugs surfaced while testing the recipes end to end:

- machine: customer/subscriptions props were dropped in local mode
  unless appId was set, so components never saw the period end
- transform: survey-attached offers lost server-resolved copy and
  decisionId in token mode, rendering default copy instead of the
  dashboard's and breaking the presented/accepted analytics join

ReasonConfig.offer now accepts OfferDecision to match the runtime,
the copy-paste templates moved from recipes/ to examples/ to free the
name, and the playground gained a prebuilt-recipes demo section plus
recipe wiring in token mode.

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

Looks good. Went through the diff against main — the recipes ride the existing customComponents contract with no core changes, and both drive-by fixes check out: initializeFromConfig still rebuilds token-mode state from the server config so the machine change is safe, and toDecision already passes through anything carrying copy so the transform change matches how standalone offer steps work. The declineCta ordering had me for a second but it's the same pattern all the built-ins use (defaultMessages.offer.declineCta is '' by design).

Two small things, fine as follow-ups:

  • The transform fix got a regression test but the machine fix didn't. Worth one asserting state.subscriptions populates in local mode without appId — that's the exact case that was broken.
  • license_plate: 'kekis' in the playground token demo reads like leftover test data — drop it or swap in something self-explanatory.

The i18n gap and the registry not being self-serve are noted in the description; agreed both are fine for v1, but let's get a ticket up for registry access before the TMF rollout.

…ibute

- Add a regression test asserting customer/subscriptions seed local-mode
  state without an appId — the exact case the machine fix restored
- Replace the leftover license_plate test attribute in the playground
  token demo with a self-explanatory plan_tier example
@PavelNikoltsev
PavelNikoltsev merged commit 53f26fa into main Jul 22, 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