All recipes
Recipe

Name your own price

A custom step that asks the customer what they'd pay if they could set the price themselves. Useful right after a 'too expensive' survey reason, or at the end of the flow as a willingness-to-pay signal. The submitted amount lands on the recorded session under customStepResults['name-your-price'] as { amount, currency } — or { amount: null } if the customer picks 'I'd rather not say'. Edit the input formatting and currency symbol logic in the source to fit your locale.

custom recipe
Question 1 of 1

What price would feel fair for what you used?

We'll consider every answer when we set next year's pricing.

The full source

Copy this file into your project. It's the same file the showcase preview above renders from.

components/NameYourPrice.tsxtsx
/**
 * "Name your own price" custom step. Asks the customer what they think the
 * subscription is worth — useful right after a survey reason like "too
 * expensive" or at the end of a flow as a willingness-to-pay signal.
 *
 * The answer lands on the recorded session under
 * `customStepResults['name-your-price']` (or whatever step `type` you
 * register), with shape `{ amount: number | null, currency: string }`.
 * `amount: null` means the customer picked "I'd rather not say".
 *
 * Wire it as a custom step:
 *
 *   <CancelFlow
 *     steps={[
 *       ...,
 *       {
 *         type: 'name-your-price',
 *         title: 'What price would feel fair for what you used?',
 *         description: "We'll consider every answer when we set next year's pricing.",
 *         data: { initial: 14, currency: 'USD', interval: 'mo' },
 *       },
 *     ]}
 *     customComponents={{ 'name-your-price': NameYourPrice }}
 *   />
 *
 * The "QUESTION N OF M" eyebrow is a brand pattern, not an SDK feature —
 * pass `step.data.questionNumber` / `step.data.totalQuestions` to show it
 * dynamically, or hardcode in the JSX below.
 */
import type { CustomStepProps } from '@churnkey/react/core'
import { useState } from 'react'

export function NameYourPrice({ step, onNext }: CustomStepProps) {
  const initial = typeof step.data?.initial === 'number' ? step.data.initial : 0
  const currency = (step.data?.currency as string | undefined) ?? 'USD'
  const interval = (step.data?.interval as string | undefined) ?? 'mo'
  const questionNumber = step.data?.questionNumber as number | undefined
  const totalQuestions = step.data?.totalQuestions as number | undefined

  const [amount, setAmount] = useState<number>(initial)

  const symbol = currencySymbol(currency)

  return (
    <div className="ck-step">
      {questionNumber != null && totalQuestions != null && (
        <div
          style={{
            fontSize: 11,
            fontWeight: 600,
            letterSpacing: '0.1em',
            textTransform: 'uppercase',
            color: 'var(--ck-color-text-muted)',
            marginBottom: 16,
          }}
        >
          Question {questionNumber} of {totalQuestions}
        </div>
      )}

      <h2 className="ck-step-title">{step.title ?? 'What price would feel fair?'}</h2>
      {step.description && <p className="ck-step-description">{step.description}</p>}

      <label
        style={{
          display: 'grid',
          gridTemplateColumns: 'auto 1fr auto',
          alignItems: 'center',
          gap: 12,
          padding: '20px 24px',
          marginTop: 8,
          marginBottom: 20,
          border: '1.5px solid var(--ck-color-text)',
          borderRadius: 'var(--ck-radius-lg)',
          background: 'var(--ck-color-surface)',
          cursor: 'text',
        }}
      >
        <span
          aria-hidden
          style={{
            fontFamily: 'var(--ck-font-display)',
            fontSize: 28,
            fontWeight: 500,
            color: 'var(--ck-color-text)',
          }}
        >
          {symbol}
        </span>
        <input
          type="text"
          inputMode="numeric"
          pattern="[0-9]*"
          value={Number.isFinite(amount) && amount > 0 ? amount : ''}
          onChange={(e) => {
            const cleaned = e.target.value.replace(/[^0-9]/g, '')
            setAmount(cleaned ? Number(cleaned) : 0)
          }}
          aria-label={`Amount in ${currency}`}
          style={{
            appearance: 'none',
            border: 'none',
            background: 'transparent',
            outline: 'none',
            fontFamily: 'var(--ck-font-display)',
            fontSize: 28,
            fontWeight: 500,
            color: 'var(--ck-color-text)',
            width: '100%',
            padding: 0,
          }}
        />
        <span
          aria-hidden
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            gap: 4,
            color: 'var(--ck-color-text-muted)',
            fontSize: 14,
            fontFamily: 'var(--ck-font-display)',
            whiteSpace: 'nowrap',
          }}
        >
          <span style={{ fontSize: 22, lineHeight: 1, transform: 'translateY(-2px)' }}>/</span>
          <span>{interval}</span>
        </span>
      </label>

      <button
        type="button"
        onClick={() => onNext({ amount: amount > 0 ? amount : null, currency })}
        disabled={amount <= 0}
        style={{
          width: '100%',
          padding: '14px 20px',
          background: 'var(--ck-color-primary)',
          color: '#fff',
          border: 'none',
          borderRadius: 'var(--ck-radius-md)',
          fontSize: 15,
          fontWeight: 600,
          fontFamily: 'inherit',
          textAlign: 'center',
          cursor: amount > 0 ? 'pointer' : 'not-allowed',
          opacity: amount > 0 ? 1 : 0.4,
          marginBottom: 14,
        }}
      >
        Submit and continue
      </button>
      <div style={{ textAlign: 'center' }}>
        <button
          type="button"
          onClick={() => onNext({ amount: null, currency })}
          style={{
            padding: '4px 8px',
            background: 'transparent',
            border: 'none',
            color: 'var(--ck-color-text-secondary)',
            fontSize: 14,
            fontFamily: 'inherit',
            cursor: 'pointer',
          }}
        >
          I&apos;d rather not say
        </button>
      </div>
    </div>
  )
}

function currencySymbol(code: string): string {
  switch (code.toUpperCase()) {
    case 'USD':
    case 'CAD':
    case 'AUD':
    case 'NZD':
      return '$'
    case 'EUR':
      return '€'
    case 'GBP':
      return '£'
    case 'JPY':
      return '¥'
    default:
      return code + ' '
  }
}

Wire it into a flow

Drop the file into your codebase and reference it from the appropriate prop on <CancelFlow>.

CancelButton.tsxtsx
import { CancelFlow } from '@churnkey/react'
import '@churnkey/react/styles.css'
import { NameYourPrice } from './components/NameYourPrice'

<CancelFlow
  steps={[
    { type: 'survey', reasons: [/* ... */] },
    {
      type: 'name-your-price',
      title: 'What price would feel fair for what you used?',
      description: "We'll consider every answer when we set next year's pricing.",
      data: { initial: 14, currency: 'USD', interval: 'mo' },
    },
    { type: 'confirm' },
  ]}
  customComponents={{ 'name-your-price': NameYourPrice }}
  handleCancel={async () => myBilling.cancel()}
/>

// The submitted price lands on the session as:
//   customStepResults['name-your-price'] = { amount: 14, currency: 'USD' }
//
// or { amount: null } if the customer chose "I'd rather not say".