From b29c667af76e894b1b9c235224765d17684e62e4 Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Tue, 19 May 2026 12:05:15 -0400 Subject: [PATCH 1/4] feat: freeform survey responses, refactor: pass customer and subscription to all steps, style: plan change step with awareness of current plan --- apps/playground/src/RecipeBrowser.tsx | 8 + packages/react/src/components/cancel-flow.tsx | 35 +++- .../src/components/steps/default-confirm.tsx | 4 +- .../src/components/steps/default-survey.tsx | 15 ++ .../steps/offer/default-plan-change-offer.tsx | 20 ++- .../components/structural/default-modal.tsx | 4 +- packages/react/src/core/format.ts | 20 +++ packages/react/src/core/index.ts | 4 +- packages/react/src/core/machine.ts | 39 ++-- packages/react/src/core/transform.ts | 3 +- packages/react/src/core/types.ts | 53 ++++-- packages/react/src/core/utils.ts | 8 + .../react/src/headless/use-cancel-flow.ts | 4 +- packages/react/src/styles/cancel-flow.css | 61 +++++-- .../tests/components/cancel-flow.test.tsx | 167 ++++++++++++++++++ packages/react/tests/core/machine.test.ts | 110 ++++++++++++ .../tests/headless/use-cancel-flow.test.tsx | 83 +++++++++ 17 files changed, 582 insertions(+), 56 deletions(-) create mode 100644 packages/react/tests/headless/use-cancel-flow.test.tsx diff --git a/apps/playground/src/RecipeBrowser.tsx b/apps/playground/src/RecipeBrowser.tsx index 7f23ac1..b5d6dfe 100644 --- a/apps/playground/src/RecipeBrowser.tsx +++ b/apps/playground/src/RecipeBrowser.tsx @@ -35,6 +35,7 @@ const RECIPES: Recipe[] = [ @@ -53,6 +54,7 @@ const RECIPES: Recipe[] = [ copy: { headline: '', body: '', cta: '', declineCta: '' }, }} customer={null} + subscriptions={[]} onAccept={noopAsync} onDecline={noop} isProcessing={false} @@ -93,6 +95,8 @@ const RECIPES: Recipe[] = [ declineCta: 'No thanks, continue', }, }} + customer={null} + subscriptions={[]} onAccept={noopAsync} onDecline={noop} isProcessing={false} @@ -117,6 +121,8 @@ const RECIPES: Recipe[] = [ declineCta: 'No thanks, continue', }, }} + customer={null} + subscriptions={[]} onAccept={noopAsync} onDecline={noop} isProcessing={false} @@ -135,6 +141,8 @@ const RECIPES: Recipe[] = [ description="You'll lose these features you put to good use:" confirmLabel="Continue cancellation" goBackLabel="Keep my subscription" + customer={null} + subscriptions={[]} onConfirm={noopAsync} onGoBack={noop} isProcessing={false} diff --git a/packages/react/src/components/cancel-flow.tsx b/packages/react/src/components/cancel-flow.tsx index 51d8a9a..90df69d 100644 --- a/packages/react/src/components/cancel-flow.tsx +++ b/packages/react/src/components/cancel-flow.tsx @@ -13,7 +13,7 @@ import type { SuccessStep, SurveyStep, } from '../core/types' -import { appearanceToStyle, defaultTitles } from '../core/utils' +import { appearanceToStyle, BUILT_IN_OFFER_TYPES, defaultTitles } from '../core/utils' import { useCancelFlowMachine } from '../headless/use-cancel-flow-machine' import { DefaultConfirm } from './steps/default-confirm' import { DefaultFeedback } from './steps/default-feedback' @@ -79,7 +79,7 @@ function LoadStatus({ return (
- +
{isLoading && ( @@ -148,7 +148,7 @@ function FlowShell({ machine, state, appearance, classNames, components, customC return (
- +
{machine.canGoBack && } @@ -185,9 +185,13 @@ function StepRenderer({ ) } + if (!BUILT_IN_OFFER_TYPES.includes(offer.type)) { + return + } const Offer = components?.Offer ?? DefaultOffer const config = stepConfig as OfferStep | undefined return ( @@ -305,6 +321,7 @@ function StepRenderer({ data: config?.data, }} customer={state.customer} + subscriptions={state.subscriptions} onNext={machine.next} onBack={machine.back} /> @@ -313,8 +330,7 @@ function StepRenderer({ } } -// Skips a custom step the consumer didn't register a component for. The skip -// runs in an effect so we don't mutate machine state during render. +// Skip runs in an effect so we don't mutate machine state during render. function UnregisteredStepFallback({ step, onSkip }: { step: string; onSkip: () => void }) { useEffect(() => { console.warn(`[churnkey] No component registered for step type "${step}". Skipping.`) @@ -322,3 +338,12 @@ function UnregisteredStepFallback({ step, onSkip }: { step: string; onSkip: () = }, [step, onSkip]) return null } + +// Pass machine.decline as onSkip so the auto-advance doesn't record an accept. +function UnregisteredOfferFallback({ offerType, onSkip }: { offerType: string; onSkip: () => void }) { + useEffect(() => { + console.warn(`[churnkey] No component registered for offer type "${offerType}". Skipping.`) + onSkip() + }, [offerType, onSkip]) + return null +} diff --git a/packages/react/src/components/steps/default-confirm.tsx b/packages/react/src/components/steps/default-confirm.tsx index 60a1e56..0928e4a 100644 --- a/packages/react/src/components/steps/default-confirm.tsx +++ b/packages/react/src/components/steps/default-confirm.tsx @@ -1,3 +1,4 @@ +import { formatPeriodEnd } from '../../core/format' import type { ConfirmStepProps } from '../../core/types' import { cn } from '../../core/utils' import { RichText } from '../rich-text' @@ -5,17 +6,18 @@ import { RichText } from '../rich-text' export function DefaultConfirm({ title, description, + subscriptions, losses, lossesLabel, confirmLabel, goBackLabel, - periodEnd, onConfirm, onGoBack, isProcessing, classNames, }: ConfirmStepProps) { const hasLosses = Array.isArray(losses) && losses.length > 0 + const periodEnd = formatPeriodEnd(subscriptions) return (

{title}

diff --git a/packages/react/src/components/steps/default-survey.tsx b/packages/react/src/components/steps/default-survey.tsx index 52fa422..a0bdbd5 100644 --- a/packages/react/src/components/steps/default-survey.tsx +++ b/packages/react/src/components/steps/default-survey.tsx @@ -30,12 +30,16 @@ export function DefaultSurvey({ reasons, selectedReason, onSelectReason, + freeformText, + onFreeformChange, onNext, classNames, components, }: SurveyStepProps) { const ReasonButton = components?.ReasonButton ?? DefaultReasonButton const listRef = useRef(null) + const selected = reasons.find((r) => r.id === selectedReason) + const showFreeform = selected?.freeform === true const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -80,6 +84,17 @@ export function DefaultSurvey({ ))}
+ {showFreeform && ( +