All recipes
Recipe

Discount — promo banner

The SDK's DefaultDiscountOffer renders a tinted card with a discount phrase ('20% off for 3 months'). This recipe replaces that pattern with a one-time-offer banner at the top, the discounted price next to a strike-through of the current price, and the duration in plain English. The strike-through reads the customer's current price from props.subscriptions[0].items[0].price.amount when one is set on <CancelFlow>. In open-source mode with no customer wired up, the recipe falls back to constants at the top of the file — edit those to whatever you want shown. The override slots in at the offer-type level, so plan-change, pause, and trial-extension keep their own defaults.

custom recipe
A one-time offer for you
Only available right now.

Stay for less

Take 20% off the next three months.

$23.99$29.99/ mo for 3 months

The full source

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

components/DiscountWithPromoBanner.tsxtsx
/**
 * Discount offer with a promo banner up top and a price-comparison block in
 * the body. Heavier than the SDK default discount card — use it when the
 * discount is the headline save and you want the customer to *feel* the
 * delta between what they pay now and what they'd pay after the offer.
 *
 * Wire it as an offer-type override:
 *
 *   <CancelFlow
 *     ...
 *     components={{ DiscountOffer: DiscountWithPromoBanner }}
 *   />
 *
 * The strike-through needs a "from" price. The recipe reads it from
 * `props.subscriptions[0].items[0].price.amount` when one is present — this
 * is how `<CancelFlow customer={...} subscriptions={...} />` flows the
 * customer's current price into every step component.
 *
 * In open-source mode (no `customer` prop, no subscription data) you don't
 * have that. Edit the FALLBACK constants below to whatever you want shown,
 * or read your own billing state in the parent component and pass it down
 * through a wrapper.
 */
import type { OfferDecision, OfferStepProps } from '@churnkey/react/core'

// Used when no subscription is on `props.subscriptions`. Minor units, to
// match the SDK's `price.amount.value` shape (cents for USD).
const FALLBACK_BASE_PRICE_MINOR = 2999
const FALLBACK_CURRENCY = 'USD'

export function DiscountWithPromoBanner({
  title,
  description,
  offer,
  subscriptions,
  onAccept,
  onDecline,
  isProcessing,
}: OfferStepProps) {
  const o = offer as OfferDecision & {
    percentOff?: number
    amountOff?: number
    durationInMonths?: number
  }

  const price = subscriptions[0]?.items[0]?.price.amount
  const baseMinor = price?.value ?? FALLBACK_BASE_PRICE_MINOR
  const currency = price?.currency ?? FALLBACK_CURRENCY
  const discountedMinor = applyDiscount(baseMinor, o)
  const headline = title ?? offer.copy.headline
  const body = description ?? offer.copy.body
  const durationLabel = o.durationInMonths
    ? `/ mo for ${o.durationInMonths} ${o.durationInMonths === 1 ? 'month' : 'months'}`
    : '/ mo'

  return (
    <div className="ck-step ck-step-offer">
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 12,
          padding: '10px 12px',
          marginBottom: 18,
          borderRadius: 'var(--ck-radius-md)',
          background: 'var(--ck-color-primary-soft)',
        }}
      >
        <span
          aria-hidden
          style={{
            display: 'grid',
            placeItems: 'center',
            width: 28,
            height: 28,
            borderRadius: '50%',
            background: 'var(--ck-color-primary)',
            color: '#fff',
            fontSize: 12.5,
            fontWeight: 700,
          }}
        >
          %
        </span>
        <div style={{ lineHeight: 1.3 }}>
          <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ck-color-text)' }}>
            A one-time offer for you
          </div>
          <div style={{ fontSize: 12.5, color: 'var(--ck-color-text-secondary)' }}>
            Only available right now.
          </div>
        </div>
      </div>

      {headline && <h2 className="ck-step-title">{headline}</h2>}
      {/* Tighten the description's own bottom margin — the price block
          below is the continuation of the same thought, not a separate
          section, so the SDK default 20px gap reads as a paragraph break
          when it should read as a beat. */}
      {body && (
        <p className="ck-step-description" style={{ marginBottom: 8 }}>
          {body}
        </p>
      )}

      <div
        style={{
          display: 'flex',
          alignItems: 'baseline',
          gap: 10,
          margin: '0 0 20px',
        }}
      >
        <span
          style={{
            fontFamily: 'var(--ck-font-display)',
            fontSize: 32,
            fontWeight: 500,
            color: 'var(--ck-color-text)',
          }}
        >
          {formatMinor(discountedMinor, currency)}
        </span>
        <span
          style={{
            fontSize: 14,
            color: 'var(--ck-color-text-muted)',
            textDecoration: 'line-through',
          }}
        >
          {formatMinor(baseMinor, currency)}
        </span>
        <span style={{ fontSize: 13, color: 'var(--ck-color-text-muted)' }}>{durationLabel}</span>
      </div>

      <button
        type="button"
        className="ck-button ck-button-primary"
        onClick={() => onAccept()}
        disabled={isProcessing}
      >
        {isProcessing ? 'Processing...' : offer.copy.cta}
      </button>
      <button type="button" className="ck-button-link" onClick={onDecline}>
        {offer.copy.declineCta}
      </button>
    </div>
  )
}

function applyDiscount(baseMinor: number, o: { percentOff?: number; amountOff?: number }): number {
  if (typeof o.percentOff === 'number') {
    return Math.round(baseMinor * (1 - o.percentOff / 100))
  }
  if (typeof o.amountOff === 'number') {
    return Math.max(0, baseMinor - o.amountOff)
  }
  return baseMinor
}

function formatMinor(minor: number, currency: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(minor / 100)
}

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 { DiscountWithPromoBanner } from './components/DiscountWithPromoBanner'

// The recipe needs the customer's current price to render the strike-through.
// Close over your billing data when you instantiate the override.
function MyDiscountOffer(props) {
  const subscription = useMySubscription()
  return (
    <DiscountWithPromoBanner
      {...props}
      basePrice={subscription.amount}
    />
  )
}

<CancelFlow
  steps={[
    {
      type: 'survey',
      reasons: [
        {
          id: 'expensive',
          label: 'Too expensive',
          offer: { type: 'discount', percentOff: 20, durationInMonths: 3 },
        },
        /* ... */
      ],
    },
    { type: 'confirm' },
  ]}
  components={{ DiscountOffer: MyDiscountOffer }}
  handleDiscount={async (o) => myBilling.applyCoupon(o.couponId)}
  handleCancel={async () => myBilling.cancel()}
/>