Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/playground/src/RecipeBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const RECIPES: Recipe[] = [
<NpsWithFaces
step={{ type: 'nps', title: 'How was your experience?' }}
customer={null}
subscriptions={[]}
onNext={noop}
onBack={noop}
/>
Expand All @@ -53,6 +54,7 @@ const RECIPES: Recipe[] = [
copy: { headline: '', body: '', cta: '', declineCta: '' },
}}
customer={null}
subscriptions={[]}
onAccept={noopAsync}
onDecline={noop}
isProcessing={false}
Expand Down Expand Up @@ -93,6 +95,8 @@ const RECIPES: Recipe[] = [
declineCta: 'No thanks, continue',
},
}}
customer={null}
subscriptions={[]}
onAccept={noopAsync}
onDecline={noop}
isProcessing={false}
Expand All @@ -117,6 +121,8 @@ const RECIPES: Recipe[] = [
declineCta: 'No thanks, continue',
},
}}
customer={null}
subscriptions={[]}
onAccept={noopAsync}
onDecline={noop}
isProcessing={false}
Expand All @@ -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}
Expand Down
35 changes: 30 additions & 5 deletions packages/react/src/components/cancel-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -79,7 +79,7 @@ function LoadStatus({

return (
<div className="ck-cancel-flow" data-color-scheme={scheme} style={appearanceStyle}>
<Modal open={true} onClose={handleClose} className={classNames?.modal}>
<Modal open={true} onClose={handleClose} className={classNames?.modal} overlayClassName={classNames?.overlay}>
<CloseButton onClose={handleClose} className={classNames?.closeButton} />
<div className="ck-content">
{isLoading && (
Expand Down Expand Up @@ -148,7 +148,7 @@ function FlowShell({ machine, state, appearance, classNames, components, customC

return (
<div className="ck-cancel-flow" data-color-scheme={scheme} style={appearanceStyle}>
<Modal open={true} onClose={machine.close} className={classNames?.modal}>
<Modal open={true} onClose={machine.close} className={classNames?.modal} overlayClassName={classNames?.overlay}>
<CloseButton onClose={machine.close} className={classNames?.closeButton} />
<div className="ck-content">
{machine.canGoBack && <BackButton onBack={machine.back} className={classNames?.backButton} />}
Expand Down Expand Up @@ -185,9 +185,13 @@ function StepRenderer({
<Survey
title={config?.title ?? defaultTitles.survey}
description={config?.description}
customer={state.customer}
subscriptions={state.subscriptions}
reasons={machine.reasons}
selectedReason={state.selectedReason}
onSelectReason={machine.selectReason}
followupResponse={state.followupResponse}
onFollowupResponseChange={machine.setFollowupResponse}
onNext={machine.next}
classNames={config?.classNames}
components={components}
Expand All @@ -206,18 +210,24 @@ function StepRenderer({
<CustomOffer
offer={offer}
customer={state.customer}
subscriptions={state.subscriptions}
onAccept={machine.accept}
onDecline={machine.decline}
isProcessing={state.isProcessing}
/>
)
}
if (!BUILT_IN_OFFER_TYPES.includes(offer.type)) {
return <UnregisteredOfferFallback offerType={offer.type} onSkip={machine.decline} />
}
const Offer = components?.Offer ?? DefaultOffer
const config = stepConfig as OfferStep | undefined
return (
<Offer
title={config?.title}
description={config?.description}
customer={state.customer}
subscriptions={state.subscriptions}
offer={offer}
onAccept={machine.accept}
onDecline={machine.decline}
Expand All @@ -235,6 +245,8 @@ function StepRenderer({
<Feedback
title={config?.title ?? defaultTitles.feedback}
description={config?.description}
customer={state.customer}
subscriptions={state.subscriptions}
placeholder={config?.placeholder}
required={config?.required ?? false}
minLength={config?.minLength ?? 0}
Expand All @@ -253,6 +265,8 @@ function StepRenderer({
<Confirm
title={config?.title ?? defaultTitles.confirm}
description={config?.description}
customer={state.customer}
subscriptions={state.subscriptions}
losses={config?.losses}
lossesLabel={config?.lossesLabel}
confirmLabel={config?.confirmLabel ?? 'Cancel subscription'}
Expand Down Expand Up @@ -281,6 +295,8 @@ function StepRenderer({
? (config?.savedDescription ?? 'Your offer has been applied.')
: (config?.cancelledDescription ?? "We're sorry to see you go.")
}
customer={state.customer}
subscriptions={state.subscriptions}
onClose={machine.close}
classNames={config?.classNames}
/>
Expand All @@ -305,6 +321,7 @@ function StepRenderer({
data: config?.data,
}}
customer={state.customer}
subscriptions={state.subscriptions}
onNext={machine.next}
onBack={machine.back}
/>
Expand All @@ -313,12 +330,20 @@ 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.`)
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
}
4 changes: 3 additions & 1 deletion packages/react/src/components/steps/default-confirm.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { formatPeriodEnd } from '../../core/format'
import type { ConfirmStepProps } from '../../core/types'
import { cn } from '../../core/utils'
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 (
<div className={cn('ck-step ck-step-confirm', classNames?.root)}>
<h2 className={cn('ck-step-title', classNames?.title)}>{title}</h2>
Expand Down
46 changes: 16 additions & 30 deletions packages/react/src/components/steps/default-survey.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useCallback, useRef } from 'react'
import type { ReasonButtonProps, SurveyStepProps } from '../../core/types'
import { cn } from '../../core/utils'
import { RichText } from '../rich-text'
Expand All @@ -12,7 +11,6 @@ function DefaultReasonButton({ reason, index, isSelected, onSelect }: ReasonButt
type="button"
role="radio"
aria-checked={isSelected}
tabIndex={isSelected ? 0 : -1}
onClick={() => onSelect(reason.id)}
className={cn('ck-reason-button', isSelected && 'ck-reason-button--selected')}
>
Expand All @@ -30,45 +28,22 @@ export function DefaultSurvey({
reasons,
selectedReason,
onSelectReason,
followupResponse,
onFollowupResponseChange,
onNext,
classNames,
components,
}: SurveyStepProps) {
const ReasonButton = components?.ReasonButton ?? DefaultReasonButton
const listRef = useRef<HTMLDivElement>(null)

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
e.preventDefault()

const currentIdx = reasons.findIndex((r) => r.id === selectedReason)
let nextIdx: number
if (e.key === 'ArrowDown') {
nextIdx = currentIdx < reasons.length - 1 ? currentIdx + 1 : 0
} else {
nextIdx = currentIdx > 0 ? currentIdx - 1 : reasons.length - 1
}
onSelectReason(reasons[nextIdx].id)

const buttons = listRef.current?.querySelectorAll<HTMLElement>('[role="radio"]')
buttons?.[nextIdx]?.focus()
},
[reasons, selectedReason, onSelectReason],
)
const selected = reasons.find((r) => r.id === selectedReason)
const showFollowup = selected?.freeform === true

return (
<div className={cn('ck-step ck-step-survey', classNames?.root)}>
<h2 className={cn('ck-step-title', classNames?.title)}>{title}</h2>
{description && <RichText html={description} className={cn('ck-step-description', classNames?.description)} />}

<div
ref={listRef}
className={cn('ck-reason-list', classNames?.reasonList)}
role="radiogroup"
aria-label={title}
onKeyDown={handleKeyDown}
>
<div className={cn('ck-reason-list', classNames?.reasonList)} role="radiogroup" aria-label={title}>
{reasons.map((reason, i) => (
<ReasonButton
key={reason.id}
Expand All @@ -80,6 +55,17 @@ export function DefaultSurvey({
))}
</div>

{showFollowup && (
<textarea
className={cn('ck-reason-followup', classNames?.followupInput)}
placeholder="Tell us more (optional)"
rows={3}
value={followupResponse}
onChange={(e) => onFollowupResponseChange(e.target.value)}
aria-label="Additional detail"
/>
)}

<button
type="button"
className={cn('ck-button ck-button-primary', classNames?.continueButton)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Checkmark } from '../shared'
export function DefaultPlanChangeOffer({
title,
description,
subscriptions,
offer,
onAccept,
onDecline,
Expand All @@ -16,7 +17,11 @@ export function DefaultPlanChangeOffer({
}: OfferStepProps) {
const o = offer as OfferDecision & { plans?: PlanOption[] }
const plans = o.plans ?? []
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(plans[0]?.id ?? null)
// Mark the customer's current plan via their first subscription's first
// price; switching to that same plan would be a no-op so it gets disabled.
const currentPlanId = subscriptions[0]?.items[0]?.price.id
const initialPlanId = plans.find((p) => p.id !== currentPlanId)?.id ?? null
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(initialPlanId)
const selectedPlan = plans.find((p) => p.id === selectedPlanId) ?? null

const headline = title ?? offer.copy.headline
Expand All @@ -38,16 +43,25 @@ export function DefaultPlanChangeOffer({
const interval = plan.duration?.interval ?? 'month'
const currency = plan.amount.currency ?? 'USD'
const isSelected = plan.id === selectedPlanId
const isCurrent = plan.id === currentPlanId

return (
<button
type="button"
key={plan.id}
onClick={() => setSelectedPlanId(plan.id)}
className={cn('ck-plan-card', isSelected && 'ck-plan-card--selected')}
disabled={isCurrent}
className={cn(
'ck-plan-card',
isSelected && 'ck-plan-card--selected',
isCurrent && 'ck-plan-card--current',
)}
aria-pressed={isSelected}
>
<div className="ck-plan-name">{plan.name ?? plan.id}</div>
<div className="ck-plan-name">
{plan.name ?? plan.id}
{isCurrent && <span className="ck-plan-current-badge">Current</span>}
</div>
{plan.tagline && <div className="ck-plan-tagline">{plan.tagline}</div>}

<div className="ck-plan-price-row">
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/components/structural/default-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function trapFocus(container: HTMLElement): () => void {
return () => container.removeEventListener('keydown', handleKeyDown)
}

export function DefaultModal({ open, onClose, children, className }: ModalProps) {
export function DefaultModal({ open, onClose, children, className, overlayClassName }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
Expand Down Expand Up @@ -74,7 +74,7 @@ export function DefaultModal({ open, onClose, children, className }: ModalProps)
return (
<div
ref={overlayRef}
className="ck-overlay"
className={cn('ck-overlay', overlayClassName)}
onClick={(e) => {
if (e.target === overlayRef.current) onClose()
}}
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/core/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface SessionPayload {
aborted?: boolean
surveyChoiceId?: string
surveyChoiceValue?: string
/** Free-text from a `freeform: true` reason. The reason's static `label` still travels on `surveyChoiceValue`. */
followupResponse?: string
feedback?: string
acceptedOffer?: AcceptedOfferPayload
presentedOffers?: PresentedOffer[]
Expand Down
20 changes: 20 additions & 0 deletions packages/react/src/core/format.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { DirectSubscription } from './types'

// Currencies whose smallest unit equals one major unit (no fractional part).
// Stripe and most billing providers store these without an implicit /100.
const ZERO_DECIMAL_CURRENCIES: ReadonlySet<string> = new Set([
Expand Down Expand Up @@ -95,6 +97,24 @@ export function formatMonthDayLong(date: Date, locale?: string): string {
return date.toLocaleDateString(locale, { month: 'long', day: 'numeric' })
}

/**
* Long-form access period end ("June 14, 2026") for the first subscription's
* current period. Returns null for canceled subscriptions, missing periods,
* and unparseable dates so Confirm can drop the "access continues until"
* notice cleanly instead of rendering "Invalid Date".
*/
export function formatPeriodEnd(
subscriptions: DirectSubscription[] | null | undefined,
locale?: string,
): string | null {
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)
if (Number.isNaN(d.getTime())) return null
return d.toLocaleDateString(locale, { month: 'long', day: 'numeric', year: 'numeric' })
}

// ─── Discount phrasing ─────────────────────────────────────────────────────

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type {
SdkStep,
SdkSurveyStep,
} from './api-types'
export { calculateDiscountedPrice, formatPrice } from './format'
export { calculateDiscountedPrice, formatPeriodEnd, formatPrice } from './format'
export { CancelFlowMachine } from './machine'
export type { ResolvedStep } from './step-graph'
export type { SessionCredentials } from './token'
Expand Down Expand Up @@ -73,4 +73,4 @@ export type {
SurveyStepProps,
TrialExtensionOffer,
} from './types'
export { appearanceToStyle, BUILT_IN_STEP_TYPES, cn, defaultTitles } from './utils'
export { appearanceToStyle, BUILT_IN_OFFER_TYPES, BUILT_IN_STEP_TYPES, cn, defaultTitles } from './utils'
Loading
Loading