All recipes
Recipe
Seat-change buckets
A custom offer type for customers who want to keep the product but pay for fewer seats. Instead of a continuous stepper, this recipe shows three presets — half the team, the core users, solo — each with its computed monthly price and savings. Presets work better than a free-form input when the choice is between obvious buckets. The accepted seat count lands on AcceptedOffer.result.seats.
custom recipe
Pick the right team size
You're on 10 seats. We can drop you to one of these instead.
The full source
Copy this file into your project. It's the same file the showcase preview above renders from.
components/SeatChangeBuckets.tsxtsx
/**
* Seat-change offer rendered as preset buckets instead of a stepper. Each
* bucket pre-frames the choice ("right-size for a small team") — useful
* when you want to nudge customers toward a specific number rather than
* give them a continuous range.
*
* Wire it as a custom offer type:
*
* <CancelFlow
* steps={[{
* type: 'survey',
* reasons: [
* { id: 'seats', label: 'Too many seats', offer: {
* type: 'change-seats',
* data: { currentSeats: 10, pricePerSeat: 10 },
* }},
* ],
* }, ...]}
* customComponents={{ 'change-seats': SeatChangeBuckets }}
* />
*
* The accepted seat count lands on `AcceptedOffer.result.seats`. Edit the
* PRESETS function to compute different buckets from the customer's data.
*/
import type { CustomOfferProps } from '@churnkey/react/core'
import { useState } from 'react'
interface SeatChangeData {
currentSeats: number
pricePerSeat: number
}
function presetsFor({ currentSeats }: SeatChangeData) {
return [
{ id: 'half', seats: Math.max(1, Math.floor(currentSeats / 2)), tagline: 'Right-size for a smaller team' },
{ id: 'core', seats: Math.max(1, Math.floor(currentSeats / 3)), tagline: 'Just the core users' },
{ id: 'solo', seats: 1, tagline: 'Solo plan' },
]
}
export function SeatChangeBuckets({ offer, onAccept, onDecline, isProcessing }: CustomOfferProps) {
const data = ((offer as { data?: SeatChangeData }).data ?? { currentSeats: 10, pricePerSeat: 10 }) as SeatChangeData
const presets = presetsFor(data)
const [pickedId, setPickedId] = useState<string>(presets[0].id)
const picked = presets.find((p) => p.id === pickedId) ?? presets[0]
const newMonthly = picked.seats * data.pricePerSeat
const savings = data.currentSeats * data.pricePerSeat - newMonthly
return (
<div className="ck-step ck-step-offer">
<h2 className="ck-step-title">Pick the right team size</h2>
<p className="ck-step-description">
You're on {data.currentSeats} seats. We can drop you to one of these instead.
</p>
<div className="ck-offer-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
{presets.map((p) => {
const isSelected = p.id === pickedId
const monthly = p.seats * data.pricePerSeat
const save = data.currentSeats * data.pricePerSeat - monthly
return (
<button
key={p.id}
type="button"
onClick={() => setPickedId(p.id)}
aria-pressed={isSelected}
style={{
appearance: 'none',
display: 'grid',
gridTemplateColumns: '1fr auto',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
border: `1.5px solid ${isSelected ? 'var(--ck-color-primary)' : 'var(--ck-color-border)'}`,
background: isSelected ? 'var(--ck-color-primary-soft)' : 'var(--ck-color-surface)',
borderRadius: 'var(--ck-radius-md)',
cursor: 'pointer',
transition: 'all var(--ck-motion-fast)',
fontFamily: 'inherit',
textAlign: 'left',
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ck-color-text)' }}>
{p.seats} seat{p.seats === 1 ? '' : 's'}
</div>
<div style={{ fontSize: 12, color: 'var(--ck-color-text-secondary)', marginTop: 2 }}>{p.tagline}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 14, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>${monthly}/mo</div>
<div
style={{
fontSize: 11,
color: 'var(--ck-color-text-muted)',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
marginTop: 1,
}}
>
save ${save}/mo
</div>
</div>
</button>
)
})}
</div>
<button
type="button"
className="ck-button ck-button-primary"
onClick={() =>
onAccept({ seats: picked.seats, previousSeats: data.currentSeats, monthlyDelta: newMonthly - data.currentSeats * data.pricePerSeat })
}
disabled={isProcessing}
>
{isProcessing ? 'Updating…' : `Reduce to ${picked.seats} seat${picked.seats === 1 ? '' : 's'} • save $${savings}/mo`}
</button>
<button type="button" className="ck-button-link" onClick={onDecline}>
No thanks, cancel
</button>
</div>
</div>
)
}
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 { SeatChangeBuckets } from './components/SeatChangeBuckets'
<CancelFlow
steps={[
{
type: 'survey',
reasons: [{
id: 'too-many-seats',
label: 'Too many seats',
offer: {
type: 'change-seats', // custom offer type
data: { currentSeats: 10, pricePerSeat: 10 },
},
}],
},
{ type: 'confirm' },
]}
customComponents={{ 'change-seats': SeatChangeBuckets }}
onAccept={async (offer) => {
if (offer.type === 'change-seats') {
await myBilling.changeSeats(offer.result.seats)
}
}}
handleCancel={async () => myBilling.cancel()}
/>