All recipes
Term extension
A prebuilt component for merchant-executed term extension offers. It reads the extension length from offer.data.days, renders the flow-authored copy with a +N days badge, and computes the new renewal date from subscriptions[0]'s current period end (the date row is omitted when no period end is available). Accepts pass { days } through onAccept, so your billing handler doesn't re-derive it. Import it from @churnkey/react/recipes — no component to build or maintain.
prebuilt component
Push your next charge back a month
Keep full access — your renewal just moves 30 days out.
+30
days
New end date
Jul 14
The full source
For reference — this component ships in the package, so import it from @churnkey/react/recipes instead of copying. Copy it only when you want to fork the presentation and own it from there.
@churnkey/react/recipestsx
import { RichText } from '../components/rich-text'
import { formatMonthDay } from '../core/format'
import { defaultMessages } from '../core/messages'
import type { CustomOfferProps, DirectSubscription } from '../core/types'
/**
* Prebuilt renderer for merchant-executed term extension offers ("delay
* your next charge by N days"). Register it under the custom offer type
* configured in the flow builder:
*
* <CancelFlow
* customComponents={{ annual_term_extension: TermExtensionOffer }}
* onAccept={handleAccept}
* />
*
* Reads `offer.data.days` (a flow-builder field), shows the extension and
* the resulting renewal date, and passes `{ days }` through `onAccept` so
* the merchant's billing handler doesn't need to re-derive it.
*/
export function TermExtensionOffer({ offer, subscriptions, onAccept, onDecline, isProcessing }: CustomOfferProps) {
const msg = defaultMessages
// OfferDecision is a union; only the custom member carries `data`.
const days = readDays((offer as { data?: Record<string, unknown> }).data)
const newEnd = extendedPeriodEnd(subscriptions, days)
return (
<div className="ck-step ck-step-offer">
{offer.copy.headline && <h2 className="ck-step-title">{offer.copy.headline}</h2>}
{offer.copy.body && <RichText as="div" html={offer.copy.body} className="ck-step-description" />}
<div className="ck-offer-card">
{days != null && (
// Same badge layout the built-in trial extension uses, so the
// recipe inherits its styling wholesale.
<div className="ck-offer-details ck-trial-block">
<div className="ck-trial-badge">
<div className="ck-trial-days">+{days}</div>
<div className="ck-trial-unit">{days === 1 ? msg.common.day : msg.common.days}</div>
</div>
{newEnd && (
<div>
<div className="ck-trial-end-label">{msg.offer.newEndDateLabel}</div>
<div className="ck-trial-end-date">{formatMonthDay(newEnd)}</div>
</div>
)}
</div>
)}
<button
type="button"
className="ck-button ck-button-primary"
onClick={() => onAccept(days != null ? { days } : undefined)}
disabled={isProcessing}
>
{isProcessing ? msg.common.processing : offer.copy.cta}
</button>
<button type="button" className="ck-button-link" onClick={onDecline}>
{msg.offer.declineCta || offer.copy.declineCta}
</button>
</div>
</div>
)
}
function readDays(data: Record<string, unknown> | undefined): number | null {
const raw = data?.days
const days = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN
return Number.isFinite(days) && days > 0 ? Math.round(days) : null
}
// Term extension delays the next charge, so the new date is the current
// period end plus the extension — unlike trial extension, which counts
// from today. Null when the period end isn't known (e.g. local mode
// without subscriptions), in which case the date row is simply omitted.
function extendedPeriodEnd(subscriptions: DirectSubscription[], days: number | null): Date | null {
if (days == null) return null
const status = subscriptions[0]?.status
if (!status || !('currentPeriod' in status) || !status.currentPeriod?.end) return null
const end = status.currentPeriod.end
const base = end instanceof Date ? new Date(end.getTime()) : new Date(end)
if (Number.isNaN(base.getTime())) return null
base.setDate(base.getDate() + days)
return base
}
Wire it into a flow
Import the component and register it on customComponents under your offer type's key.
CancelButton.tsxtsx
import { CancelFlow } from '@churnkey/react'
import { TermExtensionOffer } from '@churnkey/react/recipes'
<CancelFlow
session={token} // flow, copy, and data.days come from the dashboard
customComponents={{ annual_term_extension: TermExtensionOffer }}
onAccept={async (offer) => {
if (offer.type === 'annual_term_extension') {
const { days } = offer.result as { days: number }
await myBilling.delayNextCharge(days) // you own the billing action
}
}}
/>