Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Expect breaking changes in minor versions while we're pre-1.0.

## 0.2.0 — 2026-05-16

### Added

- `ConfirmStep.losses?: string[]` — optional bullet list of what the customer is giving up. `DefaultConfirm` renders a styled list between the description and the period-end notice. Naming losses concretely is more honest than waving at them in a generic line of copy.
- `ConfirmStep.lossesLabel?: string` — heading shown above the loss list. Defaults to `"You'll lose access to:"`.
- `ConfirmClassNames.lossList`, `lossLabel`, `lossItem`, `lossBullet` — className slots for the loss-list pieces, alongside the existing `title` / `description` / `confirmButton` / etc.
- `AppearanceVariables` widened from 11 to 25 typed keys: `colorSurface`, `colorSurfaceMuted`, `colorBorderStrong`, `colorTextMuted`, `colorPrimarySoft`, `colorSuccessSoft`, `colorDangerHover`, `colorDangerSoft`, `fontFamilyMono`, `fontFamilyDisplay`, `stepTitleWeight`, `stepTitleLetterSpacing`. The underlying `--ck-*` CSS custom properties have always worked; this is the JS-side typed surface catching up.
- `formatMonthDayLong(date)` exported from `@churnkey/react/core` — long-month form (`"April 30"`) for prominent date displays. Used by the redesigned `DefaultPauseOffer`.

### Changed

- **`DefaultPauseOffer` redesigned.** The resume date is now the typographic anchor — a small `We'll see you back on` kicker over a display-font date — with ink-filled month chips beneath it as the subordinate control. The previous segment-selector + calendar-callout layout is gone. Component props are unchanged; this is a visual rewrite.
- `.ck-success-icon` swapped from green-on-green to sand-disc-with-ink-check. The check itself is the success cue; the disc is just elevation.

### Removed

- CSS class names from the old pause layout: `.ck-pause-segments`, `.ck-pause-segment`, `.ck-pause-segment--selected`, `.ck-pause-resume`, `.ck-pause-resume-label`, `.ck-pause-resume-date`, `.ck-pause-resume-icon`. **Breaking** if you targeted these from your own stylesheet for additional theming — replace with `.ck-pause-card`, `.ck-pause-eyebrow`, `.ck-pause-date`, `.ck-pause-chips`, `.ck-pause-chip`, `.ck-pause-chip--selected`.

### Notes

- All breaking changes are CSS-class renames. No JS API was removed.

## 0.1.2 — 2026-05-01

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@churnkey/react",
"version": "0.1.2",
"version": "0.2.0",
"description": "Production-ready cancel flow for React. Drop-in component, headless hook, or full customization. Works standalone or with Churnkey for AI-powered retention.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/components/cancel-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ function StepRenderer({
<Confirm
title={config?.title ?? defaultTitles.confirm}
description={config?.description}
losses={config?.losses}
lossesLabel={config?.lossesLabel}
confirmLabel={config?.confirmLabel ?? 'Cancel subscription'}
goBackLabel={config?.goBackLabel ?? 'Go back'}
onConfirm={machine.cancel}
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/components/steps/default-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { RichText } from '../rich-text'
export function DefaultConfirm({
title,
description,
losses,
lossesLabel,
confirmLabel,
goBackLabel,
periodEnd,
Expand All @@ -13,11 +15,28 @@ export function DefaultConfirm({
isProcessing,
classNames,
}: ConfirmStepProps) {
const hasLosses = Array.isArray(losses) && losses.length > 0
return (
<div className={cn('ck-step ck-step-confirm', classNames?.root)}>
<h2 className={cn('ck-step-title', classNames?.title)}>{title}</h2>
{description && <RichText html={description} className={cn('ck-step-description', classNames?.description)} />}

{hasLosses && (
<div className={cn('ck-loss-block', classNames?.lossList)}>
<div className={cn('ck-loss-label', classNames?.lossLabel)}>{lossesLabel ?? "You'll lose access to:"}</div>
<ul className="ck-loss-list">
{losses.map((item) => (
<li key={item} className={cn('ck-loss-item', classNames?.lossItem)}>
<span aria-hidden className={cn('ck-loss-bullet', classNames?.lossBullet)}>
</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
)}

{periodEnd && (
<p className={cn('ck-period-end', classNames?.periodEndNotice)}>Your access continues until {periodEnd}.</p>
)}
Expand Down
97 changes: 32 additions & 65 deletions packages/react/src/components/steps/offer/default-pause-offer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { formatShortDate } from '../../../core/format'
import { formatMonthDayLong } from '../../../core/format'
import type { OfferDecision, OfferStepProps } from '../../../core/types'
import { cn } from '../../../core/utils'
import { RichText } from '../../rich-text'
Expand All @@ -15,81 +15,48 @@ export function DefaultPauseOffer({
}: OfferStepProps) {
const o = offer as OfferDecision & { months: number }
const max = Math.max(1, o.months)
// Default to 2 months when the offer allows it — long enough to feel like
// a real break, short enough that most subscribers come back.
const [months, setMonths] = useState<number>(Math.min(2, max))
const [months, setMonths] = useState<number>(1)

const headline = title ?? offer.copy.headline
const body = description ?? offer.copy.body
const options = Array.from({ length: max }, (_, i) => i + 1)
const resumeAt = new Date()
resumeAt.setMonth(resumeAt.getMonth() + months)
const resumeDate = formatShortDate(resumeAt)

const resume = new Date()
resume.setMonth(resume.getMonth() + months)
const resumeDate = formatMonthDayLong(resume)

return (
<div className={cn('ck-step ck-step-offer', classNames?.root)}>
{headline && <h2 className={cn('ck-step-title', classNames?.title)}>{headline}</h2>}
{body && <RichText html={body} className={cn('ck-step-description', classNames?.description)} />}

<div className={cn('ck-offer-card', classNames?.card)}>
<div className="ck-offer-details ck-offer-pause">
<div className="ck-pause-segments" style={{ gridTemplateColumns: `repeat(${max}, 1fr)` }}>
{options.map((m) => {
const isSelected = m === months
return (
<button
key={m}
type="button"
onClick={() => setMonths(m)}
className={cn('ck-pause-segment', isSelected && 'ck-pause-segment--selected')}
>
{m} {m === 1 ? 'month' : 'months'}
</button>
)
})}
</div>
<div className="ck-pause-resume">
<div>
<div className="ck-pause-resume-label">Billing resumes</div>
<div className="ck-pause-resume-date">{resumeDate}</div>
</div>
<CalendarIcon />
</div>
<div className={cn('ck-offer-card ck-pause-card', classNames?.card)}>
<div className="ck-pause-eyebrow">We&apos;ll see you back on</div>
<div className="ck-pause-date">{resumeDate}</div>
<div className={cn('ck-pause-chips', classNames?.pauseSlider)}>
{Array.from({ length: max }, (_, i) => i + 1).map((m) => (
<button
key={m}
type="button"
onClick={() => setMonths(m)}
className={cn('ck-pause-chip', m === months && 'ck-pause-chip--selected')}
aria-pressed={m === months}
>
{m} {m === 1 ? 'month' : 'months'}
</button>
))}
</div>
<button
type="button"
className={cn('ck-button ck-button-primary', classNames?.acceptButton)}
onClick={() => onAccept({ months })}
disabled={isProcessing}
>
{isProcessing ? 'Processing...' : offer.copy.cta}
</button>
<button type="button" className={cn('ck-button-link', classNames?.declineButton)} onClick={onDecline}>
{offer.copy.declineCta}
</button>
</div>
<button
type="button"
className={cn('ck-button ck-button-primary', classNames?.acceptButton)}
onClick={() => onAccept({ months })}
disabled={isProcessing}
>
{isProcessing ? 'Processing...' : offer.copy.cta}
</button>
<button type="button" className={cn('ck-button-link', classNames?.declineButton)} onClick={onDecline}>
{offer.copy.declineCta}
</button>
</div>
)
}

function CalendarIcon() {
return (
<svg
className="ck-pause-resume-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="18" rx="3" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
)
}
5 changes: 5 additions & 0 deletions packages/react/src/core/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export function formatMonthDay(date: Date, locale?: string): string {
return date.toLocaleDateString(locale, { month: 'short', day: 'numeric' })
}

/** "April 30" — long month form for prominent date displays. */
export function formatMonthDayLong(date: Date, locale?: string): string {
return date.toLocaleDateString(locale, { month: 'long', day: 'numeric' })
}

// ─── Discount phrasing ─────────────────────────────────────────────────────

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/core/step-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export interface ResolvedStep {
required?: boolean
minLength?: number

// confirm
losses?: string[]
lossesLabel?: string

// success
savedTitle?: string
savedDescription?: string
Expand Down Expand Up @@ -159,6 +163,8 @@ function normalizeStep(step: Step, index: number): ResolvedStep {
...base,
title: s.title,
description: s.description,
losses: s.losses,
lossesLabel: s.lossesLabel,
classNames: s.classNames,
}
}
Expand Down
72 changes: 68 additions & 4 deletions packages/react/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ export interface ConfirmStep {
guid?: string
title?: string
description?: string
/** Optional bullet list of what the customer is giving up. Rendered between the description and the period-end notice. */
losses?: string[]
/** Heading above the loss list. Defaults to "You'll lose access to:". */
lossesLabel?: string
confirmLabel?: string
goBackLabel?: string
classNames?: ConfirmClassNames
Expand Down Expand Up @@ -318,6 +322,10 @@ export interface ConfirmClassNames {
root?: string
title?: string
description?: string
lossList?: string
lossLabel?: string
lossItem?: string
lossBullet?: string
confirmButton?: string
goBackButton?: string
periodEndNotice?: string
Expand All @@ -342,18 +350,72 @@ export interface StructuralClassNames {

// ─── Appearance ──────────────────────────────────────────────────────────────

/**
* Typed surface for `appearance.variables`. Every key maps to a `--ck-*`
* CSS custom property. Consumers who need a token not in this list can
* still set the underlying CSS variable directly — these are the ones
* exposed through the typed JS API.
*/
export interface AppearanceVariables {
colorPrimary: string
colorPrimaryHover: string
// Surfaces
colorBackground: string
colorSurface: string
colorSurfaceMuted: string

// Borders
colorBorder: string
colorBorderStrong: string

// Text
colorText: string
colorTextSecondary: string
colorBorder: string
colorDanger: string
colorTextMuted: string

// Primary
colorPrimary: string
colorPrimaryHover: string
colorPrimarySoft: string

// Semantic
colorSuccess: string
colorSuccessSoft: string
colorDanger: string
colorDangerHover: string
colorDangerSoft: string

// Typography
fontFamily: string
fontFamilyMono: string
/**
* Display face used by step titles and other visual headlines. Defaults
* to `fontFamily`. Set this when your brand uses a separate display face
* (Tiempos, Canela, etc.) for headings.
*/
fontFamilyDisplay: string
fontSize: string
/** Weight applied to the step title. Default `'600'`. */
fontWeightDisplay: string
/** Letter spacing applied to the step title. Default `'-0.015em'`. */
letterSpacingDisplay: string

// Geometry
borderRadius: string
radiusSm: string
radiusMd: string
radiusLg: string
radiusXl: string

// Elevation
shadowModal: string
shadowCard: string

// Overlay
/**
* Color of the dim behind the modal. Accepts any CSS color (rgba,
* color-mix, etc.). Defaults to a tinted version of the primary color
* so any primary swap automatically re-tints the overlay.
*/
overlayColor: string
}

export interface Appearance {
Expand Down Expand Up @@ -480,6 +542,8 @@ export interface FeedbackStepProps {
export interface ConfirmStepProps {
title: string
description?: string
losses?: string[]
lossesLabel?: string
confirmLabel: string
goBackLabel: string
periodEnd?: string
Expand Down
Loading
Loading