All recipes
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.
Stay for less
Take 20% off the next three months.
The full source
Copy this file into your project. It's the same file the showcase preview above renders from.
/**
* 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>.
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()}
/>