Skip to content

feat(react): customer-aware step components and freeform survey#5

Merged
robert-moore merged 4 commits into
mainfrom
rob/refactor-props
May 19, 2026
Merged

feat(react): customer-aware step components and freeform survey#5
robert-moore merged 4 commits into
mainfrom
rob/refactor-props

Conversation

@robert-moore
Copy link
Copy Markdown
Contributor

@robert-moore robert-moore commented May 19, 2026

Summary

Three threads bundled here, all consequences of the same idea: step components should see the same customer the machine sees, so they can render context-aware UI without consumer code shuttling props around.

  1. Survey gains a follow-up text input for reasons marked freeform: true.
  2. customer and subscriptions are passed to every step, not just custom ones.
  3. The plan-change and confirm defaults become the first consumers, using subscriptions to detect the current plan and the period end.

While the public surface is touching, a few props that were declared but never wired to behavior come out (PauseOffer.datePicker, PlanChangeOffer.currentPlanId, ConfirmStepProps.periodEnd, CancelFlowProps.layout, CancelFlowProps.animation).

Changes

Follow-up text on survey reasons

When a reason has freeform: true, DefaultSurvey reveals a textarea below the reason list. The typed value lands on the session payload as followupResponse. The reason's static label continues to travel as surveyChoiceValue and its id as surveyChoiceId, so dashboard groupings by reason stay stable.

This mirrors the embed widget's payload shape: surveyChoiceValue is always the label, follow-up text always lives on followupResponse.

New on the machine: followupResponse state and setFollowupResponse action. Both surfaced on the useCancelFlow return value for headless consumers.

customer and subscriptions on every step

SurveyStepProps, OfferStepProps, FeedbackStepProps, ConfirmStepProps, and SuccessStepProps all receive customer: DirectCustomer | null and subscriptions: DirectSubscription[]. Previously these were available only on CustomStepProps / CustomOfferProps.

Plan-change is current-plan aware

DefaultPlanChangeOffer reads subscriptions[0].items[0].price.id to identify the current plan. That card renders disabled with a Current badge; the initial selection seeds to the first non-current plan. PlanChangeOffer.currentPlanId removed since it can be derived.

Confirm derives period end from subscriptions

ConfirmStepProps.periodEnd removed. DefaultConfirm now calls formatPeriodEnd(subscriptions), which returns null for canceled, missing, or unparseable cases so the "access continues until..." notice is omitted cleanly instead of rendering Invalid Date.

formatPeriodEnd is exported from @churnkey/react/core for consumers reusing the logic.

Unregistered offer types auto-skip

A flow whose offer type isn't in BUILT_IN_OFFER_TYPES and has no CustomOffer registered now auto-declines and advances. Same shape as the existing unregistered-step fallback. BUILT_IN_OFFER_TYPES is now exported.

Overlay customization

ModalProps gains overlayClassName. StructuralClassNames.overlay is threaded through CancelFlow, so the overlay can be styled without replacing the whole Modal. The default overlay color also changes from a primary-tinted color-mix(...) to a neutral translucent ink; brands that want the old behavior can set --ck-overlay-color back to the color-mix expression.

API trim

Removed fields (none were wired to behavior):

  • PauseOffer.datePicker
  • PlanChangeOffer.currentPlanId
  • ConfirmStepProps.periodEnd
  • CancelFlowProps.layout
  • CancelFlowProps.animation

AcceptedOffer.reasonId becomes optional. It's only present when the offer was routed from a survey reason; standalone OfferSteps have no reason to carry. decisionId is stripped from AcceptedOffer (it was SDK-internal leaking through).

Headless hook

useCancelFlow now also returns retry.

Breaking changes

Pre-1.0 minor.

  • Any consumer overriding a default step component (Survey, Offer, Feedback, Confirm, Success) must accept the new customer and subscriptions props. TypeScript will flag each site.
  • Survey overrides should rename freeformText / onFreeformChange to followupResponse / onFollowupResponseChange. The freeformInput className slot is now followupInput. The internal ck-reason-freeform CSS class is ck-reason-followup.
  • Headless consumers: setFreeformText is now setFollowupResponse.
  • Removed props (periodEnd, currentPlanId, datePicker, layout, animation) are no longer accepted. None previously did anything.
  • AcceptedOffer.reasonId is now string | undefined. Consumers reading it should handle the standalone-offer case.
  • Session payload: typed follow-up text now travels on followupResponse rather than overloading surveyChoiceValue. Any analytics consumer that was reading the typed text from surveyChoiceValue should switch to followupResponse. Dashboard groupings by reason label become more reliable as a result.

Tests

  • packages/react/tests/headless/use-cancel-flow.test.tsx (new): covers the headless hook surface including followupResponse and retry.
  • cancel-flow.test.tsx: adds the follow-up textarea path and the current-plan disabled state.
  • machine.test.ts: adds follow-up state transitions, period-end derivation, unregistered-offer auto-skip.

…tion to all steps, style: plan change step with awareness of current plan
Rename the field and action surface so the SDK's vocabulary matches the
embed widget, and split the typed text onto its own session field so
dashboard groupings by reason label stay stable.

- FlowState/SurveyStepProps: freeformText → followupResponse,
  onFreeformChange → onFollowupResponseChange
- Machine: setFreeformText → setFollowupResponse
- SurveyClassNames.freeformInput → followupInput
- CSS .ck-reason-freeform → .ck-reason-followup
- SessionPayload: add followupResponse; surveyChoiceValue is now always
  the static reason label rather than dual-use for typed text

ReasonConfig.freeform stays as the discriminator — only naming downstream
of it moves.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR makes React cancel-flow steps customer/subscription aware, adds follow-up survey responses, and updates default step behavior to use subscription context for plan-change and confirmation UI.

Changes:

  • Adds followupResponse state/payload support for freeform survey reasons and exposes it through components/headless hook.
  • Passes customer and subscriptions through default/custom step props and uses subscriptions in plan-change/current-plan and confirm/period-end rendering.
  • Adds overlay class customization, exports offer utilities/formatting, trims unused public props, and adds tests/playground updates.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/react/tests/headless/use-cancel-flow.test.tsx Adds headless hook coverage for retry.
packages/react/tests/core/merge-fields.test.ts Updates offer copy merge assertions for broader offer shape.
packages/react/tests/core/machine.test.ts Adds machine tests for customer/subscriptions, follow-up state, and standalone offers.
packages/react/tests/components/cancel-flow.test.tsx Adds component tests for overlay class, freeform textarea, period-end notice, unknown offers, and current plan UI.
packages/react/src/styles/cancel-flow.css Adds follow-up textarea/current-plan styling and updates overlay default.
packages/react/src/headless/use-cancel-flow.ts Exposes retry and setFollowupResponse.
packages/react/src/core/utils.ts Exports built-in offer type list.
packages/react/src/core/types.ts Updates public types for follow-up responses, subscriptions, accepted offers, and removed props.
packages/react/src/core/transform.ts Maps SDK freeform/offer fields to the updated runtime shape.
packages/react/src/core/step-graph.ts Normalizes standalone offer steps with synthesized copy.
packages/react/src/core/merge-fields.ts Supports merge-field processing for offers without copy.
packages/react/src/core/machine.ts Adds follow-up state/payload handling and customer/subscription initial state.
packages/react/src/core/index.ts Exports formatPeriodEnd and BUILT_IN_OFFER_TYPES.
packages/react/src/core/format.ts Adds period-end formatting helper.
packages/react/src/core/api.ts Adds followupResponse to session payload.
packages/react/src/components/structural/default-modal.tsx Threads overlay class name into the modal overlay.
packages/react/src/components/steps/offer/default-plan-change-offer.tsx Uses subscriptions to mark/disable the current plan.
packages/react/src/components/steps/default-survey.tsx Renders follow-up textarea for freeform reasons.
packages/react/src/components/steps/default-confirm.tsx Derives period-end notice from subscriptions.
packages/react/src/components/cancel-flow.tsx Passes customer/subscriptions to steps and skips unregistered offers.
apps/playground/src/RecipeBrowser.tsx Updates playground examples for new required props.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const status = subscriptions?.[0]?.status
if (!status || !('currentPeriod' in status) || !status.currentPeriod?.end) return null
const end = status.currentPeriod.end
const d = end instanceof Date ? end : new Date(end)
Comment on lines +203 to +204
* The text the customer types lands on the session as `surveyChoiceValue`
* (the reason's `id` still travels as `surveyChoiceId`).
@@ -308,7 +311,12 @@ export class CancelFlowMachine {

selectReason = (id: string): void => {
if (!this.reasons.find((r) => r.id === id)) return
aria-label={title}
onKeyDown={handleKeyDown}
>
<div className={cn('ck-reason-list', classNames?.reasonList)} role="radiogroup" aria-label={title}>
Comment on lines +191 to +192
/** Payload from custom offers — whatever your component passed to
* `onAccept(result)`. Built-in offer types do not populate this. */
@robert-moore robert-moore merged commit 4439ea4 into main May 19, 2026
5 checks 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