diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 15d45cd..6cd26ec 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -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 diff --git a/packages/react/package.json b/packages/react/package.json index 8dd3913..69de2bb 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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": { diff --git a/packages/react/src/components/cancel-flow.tsx b/packages/react/src/components/cancel-flow.tsx index fa5fe8c..51d8a9a 100644 --- a/packages/react/src/components/cancel-flow.tsx +++ b/packages/react/src/components/cancel-flow.tsx @@ -253,6 +253,8 @@ function StepRenderer({ 0 return (

{title}

{description && } + {hasLosses && ( +
+
{lossesLabel ?? "You'll lose access to:"}
+
    + {losses.map((item) => ( +
  • + + ● + + {item} +
  • + ))} +
+
+ )} + {periodEnd && (

Your access continues until {periodEnd}.

)} diff --git a/packages/react/src/components/steps/offer/default-pause-offer.tsx b/packages/react/src/components/steps/offer/default-pause-offer.tsx index 6ce4d1d..dd2ac07 100644 --- a/packages/react/src/components/steps/offer/default-pause-offer.tsx +++ b/packages/react/src/components/steps/offer/default-pause-offer.tsx @@ -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' @@ -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(Math.min(2, max)) + const [months, setMonths] = useState(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 (
{headline &&

{headline}

} {body && } -
-
-
- {options.map((m) => { - const isSelected = m === months - return ( - - ) - })} -
-
-
-
Billing resumes
-
{resumeDate}
-
- -
+
+
We'll see you back on
+
{resumeDate}
+
+ {Array.from({ length: max }, (_, i) => i + 1).map((m) => ( + + ))}
- -
+ +
) } - -function CalendarIcon() { - return ( - - ) -} diff --git a/packages/react/src/core/format.ts b/packages/react/src/core/format.ts index 5287836..81c6710 100644 --- a/packages/react/src/core/format.ts +++ b/packages/react/src/core/format.ts @@ -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 ───────────────────────────────────────────────────── /** diff --git a/packages/react/src/core/step-graph.ts b/packages/react/src/core/step-graph.ts index f19211a..dec64bb 100644 --- a/packages/react/src/core/step-graph.ts +++ b/packages/react/src/core/step-graph.ts @@ -46,6 +46,10 @@ export interface ResolvedStep { required?: boolean minLength?: number + // confirm + losses?: string[] + lossesLabel?: string + // success savedTitle?: string savedDescription?: string @@ -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, } } diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index 2e6bcb8..15619c2 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -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 @@ -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 @@ -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 { @@ -480,6 +542,8 @@ export interface FeedbackStepProps { export interface ConfirmStepProps { title: string description?: string + losses?: string[] + lossesLabel?: string confirmLabel: string goBackLabel: string periodEnd?: string diff --git a/packages/react/src/core/utils.ts b/packages/react/src/core/utils.ts index a113aac..6f6840b 100644 --- a/packages/react/src/core/utils.ts +++ b/packages/react/src/core/utils.ts @@ -11,17 +11,53 @@ export function cn(...classes: (string | undefined | null | false)[]): string { // CSS custom properties. Light/dark variants live in CSS — these // overrides apply on top of whichever scheme is active. const VAR_MAP: Record = { - '--ck-color-primary': 'colorPrimary', - '--ck-color-primary-hover': 'colorPrimaryHover', + // Surfaces '--ck-color-bg': 'colorBackground', + '--ck-color-surface': 'colorSurface', + '--ck-color-surface-muted': 'colorSurfaceMuted', + + // Borders + '--ck-color-border': 'colorBorder', + '--ck-color-border-strong': 'colorBorderStrong', + + // Text '--ck-color-text': 'colorText', '--ck-color-text-secondary': 'colorTextSecondary', - '--ck-color-border': 'colorBorder', - '--ck-color-danger': 'colorDanger', + '--ck-color-text-muted': 'colorTextMuted', + + // Primary + '--ck-color-primary': 'colorPrimary', + '--ck-color-primary-hover': 'colorPrimaryHover', + '--ck-color-primary-soft': 'colorPrimarySoft', + + // Semantic '--ck-color-success': 'colorSuccess', + '--ck-color-success-soft': 'colorSuccessSoft', + '--ck-color-danger': 'colorDanger', + '--ck-color-danger-hover': 'colorDangerHover', + '--ck-color-danger-soft': 'colorDangerSoft', + + // Typography '--ck-font-family': 'fontFamily', + '--ck-font-mono': 'fontFamilyMono', + '--ck-font-display': 'fontFamilyDisplay', '--ck-font-size': 'fontSize', + '--ck-step-title-weight': 'fontWeightDisplay', + '--ck-step-title-letter-spacing': 'letterSpacingDisplay', + + // Geometry '--ck-border-radius': 'borderRadius', + '--ck-radius-sm': 'radiusSm', + '--ck-radius-md': 'radiusMd', + '--ck-radius-lg': 'radiusLg', + '--ck-radius-xl': 'radiusXl', + + // Elevation + '--ck-shadow-modal': 'shadowModal', + '--ck-shadow-card': 'shadowCard', + + // Overlay + '--ck-overlay-color': 'overlayColor', } export function appearanceToStyle(appearance?: Appearance): CSSProperties | undefined { diff --git a/packages/react/src/styles/cancel-flow.css b/packages/react/src/styles/cancel-flow.css index a2ad000..1bdccc0 100644 --- a/packages/react/src/styles/cancel-flow.css +++ b/packages/react/src/styles/cancel-flow.css @@ -45,8 +45,19 @@ /* Typography */ --ck-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif; --ck-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + /* Display face used by step titles and other visual headlines. Defaults + to the body family so existing consumers see no change; set it to a + dedicated display face (Tiempos, Canela, Source Serif, etc.) to give + headings their own treatment without touching the selector. */ + --ck-font-display: var(--ck-font-family); --ck-font-size: 14px; + /* Step title typography — split out so brands that ship a display face at + a single weight (Tiempos Medium, etc.) can set the weight and letter- + spacing without overriding the selector. */ + --ck-step-title-weight: 600; + --ck-step-title-letter-spacing: -0.015em; + /* Geometry */ --ck-radius-sm: 6px; --ck-radius-md: 8px; @@ -54,6 +65,19 @@ --ck-radius-xl: 16px; --ck-border-radius: var(--ck-radius-lg); + /* Elevation — shadow tokens for the modal and for inner "card" elements + (selected reasons, plan cards, pause segments). Brands can flatten or + deepen the elevation language by overriding these. */ + --ck-shadow-modal: 0 20px 25px -5px rgba(12, 10, 9, 0.1), 0 8px 10px -6px rgba(12, 10, 9, 0.06); + --ck-shadow-card: 0 1px 2px rgba(12, 10, 9, 0.04), 0 1px 3px rgba(12, 10, 9, 0.06); + + /* Overlay tint behind the modal. Defaults to a tinted version of the + primary color so any primary swap naturally re-tints the overlay. + Falls back gracefully on browsers without color-mix() support — the + declaration is invalid in those, so the rule below it (or the inline + style) wins. */ + --ck-overlay-color: color-mix(in srgb, var(--ck-color-primary) 40%, transparent); + /* Motion */ --ck-motion-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); --ck-motion-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); @@ -120,7 +144,12 @@ display: flex; align-items: center; justify-content: center; + /* Fallback for browsers without color-mix() support — used if the token + evaluates to an invalid value. Modern engines (Chrome 111+, Safari + 16.2+, Firefox 113+) resolve --ck-overlay-color from the :root rule. */ background: rgba(12, 10, 9, 0.5); + /* biome-ignore lint/suspicious/noDuplicateProperties: progressive enhancement fallback */ + background: var(--ck-overlay-color); z-index: 9999; } @@ -138,9 +167,7 @@ .ck-cancel-flow .ck-modal { background: var(--ck-color-surface); border-radius: var(--ck-radius-xl); - box-shadow: - 0 20px 25px -5px rgba(12, 10, 9, 0.1), - 0 8px 10px -6px rgba(12, 10, 9, 0.06); + box-shadow: var(--ck-shadow-modal); width: 100%; max-width: 440px; max-height: 90vh; @@ -149,11 +176,10 @@ } /* The default modal shadow is built for white surfaces; deepen it on */ -/* dark so it reads as elevation rather than a black bar. */ -.ck-cancel-flow[data-color-scheme="dark"] .ck-modal { - box-shadow: - 0 20px 25px -5px rgba(0, 0, 0, 0.5), - 0 8px 10px -6px rgba(0, 0, 0, 0.4); +/* dark so it reads as elevation rather than a black bar. Consumers can */ +/* override --ck-shadow-modal at this scope to control the dark variant. */ +.ck-cancel-flow[data-color-scheme="dark"] { + --ck-shadow-modal: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.4); } /* --- Close button (sole chrome element) --- */ @@ -232,13 +258,22 @@ } .ck-cancel-flow .ck-step-title { + font-family: var(--ck-font-display); font-size: 22px; - font-weight: 600; - letter-spacing: -0.015em; + font-weight: var(--ck-step-title-weight); + letter-spacing: var(--ck-step-title-letter-spacing); + line-height: 1.2; color: var(--ck-color-text); margin: 0 0 8px; } +/* When the title isn't followed by a description, the only thing between + it and the step body is the title's own 8px margin. That's too tight — + give it space that matches the rhythm of title → description → body. */ +.ck-cancel-flow .ck-step-title:not(:has(+ .ck-step-description)) { + margin-bottom: 28px; +} + .ck-cancel-flow .ck-step-description { font-size: 14px; color: var(--ck-color-text-secondary); @@ -344,9 +379,7 @@ .ck-cancel-flow .ck-reason-button--selected { border-color: var(--ck-color-primary); - box-shadow: - 0 1px 2px rgba(12, 10, 9, 0.04), - 0 1px 3px rgba(12, 10, 9, 0.06); + box-shadow: var(--ck-shadow-card); } .ck-cancel-flow .ck-reason-badge { @@ -403,72 +436,70 @@ } .ck-cancel-flow .ck-offer-discount-phrase { + font-family: var(--ck-font-display); font-size: 26px; - font-weight: 600; - letter-spacing: -0.015em; + font-weight: var(--ck-step-title-weight); + letter-spacing: var(--ck-step-title-letter-spacing); line-height: 1.2; color: var(--ck-color-text); } -/* Pause: segmented duration + resume-date callout. */ -.ck-cancel-flow .ck-pause-segments { - display: grid; - gap: 6px; +/* Pause: resume date is the typographic anchor, month chips below. */ +.ck-cancel-flow .ck-pause-card { + padding: 32px 24px 22px; background: var(--ck-color-surface-muted); - padding: 4px; - border-radius: var(--ck-radius-md); - margin-bottom: 12px; + border-radius: var(--ck-radius-lg); + text-align: center; + margin: 8px 0 20px; } -.ck-cancel-flow .ck-pause-segment { - appearance: none; - padding: 10px 8px; - border: none; - border-radius: var(--ck-radius-sm); - background: transparent; - font-size: 14px; +.ck-cancel-flow .ck-pause-eyebrow { + font-size: 11.5px; font-weight: 600; - font-family: inherit; - color: var(--ck-color-text-secondary); - cursor: pointer; - transition: all var(--ck-motion-fast); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ck-color-text-muted); + margin-bottom: 6px; } -.ck-cancel-flow .ck-pause-segment--selected { - background: var(--ck-color-surface); +.ck-cancel-flow .ck-pause-date { + font-family: var(--ck-font-display); + font-size: 36px; + font-weight: 500; color: var(--ck-color-text); - box-shadow: - 0 1px 2px rgba(12, 10, 9, 0.04), - 0 1px 3px rgba(12, 10, 9, 0.06); + letter-spacing: -0.015em; + line-height: 1.1; + margin-bottom: 22px; } -.ck-cancel-flow .ck-pause-resume { +.ck-cancel-flow .ck-pause-chips { display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - background: var(--ck-color-primary-soft); - border-radius: var(--ck-radius-md); -} - -.ck-cancel-flow .ck-pause-resume-label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--ck-color-primary); + justify-content: center; + gap: 6px; + flex-wrap: wrap; } -.ck-cancel-flow .ck-pause-resume-date { - font-size: 14px; +.ck-cancel-flow .ck-pause-chip { + appearance: none; + padding: 8px 14px; + font-size: 13px; font-weight: 600; - color: var(--ck-color-text); - font-variant-numeric: tabular-nums; - margin-top: 2px; + font-family: inherit; + color: var(--ck-color-text-secondary); + background: var(--ck-color-surface); + border: 1px solid var(--ck-color-border); + border-radius: var(--ck-radius-md); + cursor: pointer; + transition: + background var(--ck-motion-fast), + color var(--ck-motion-fast), + border-color var(--ck-motion-fast); } -.ck-cancel-flow .ck-pause-resume-icon { - color: var(--ck-color-primary); +.ck-cancel-flow .ck-pause-chip--selected { + background: var(--ck-color-primary); + color: #fff; + border-color: var(--ck-color-primary); } /* Trial extension: day count badge + new end date in accent-soft container. */ @@ -518,8 +549,9 @@ } .ck-cancel-flow .ck-trial-end-date { + font-family: var(--ck-font-display); font-size: 16px; - font-weight: 600; + font-weight: var(--ck-step-title-weight); color: var(--ck-color-text); margin-top: 2px; } @@ -578,14 +610,13 @@ .ck-cancel-flow .ck-plan-card--selected { border-color: var(--ck-color-primary); - box-shadow: - 0 4px 6px -1px rgba(12, 10, 9, 0.06), - 0 2px 4px -2px rgba(12, 10, 9, 0.06); + box-shadow: var(--ck-shadow-card); } .ck-cancel-flow .ck-plan-name { + font-family: var(--ck-font-display); font-size: 16px; - font-weight: 600; + font-weight: var(--ck-step-title-weight); letter-spacing: -0.01em; color: var(--ck-color-text); margin-bottom: 4px; @@ -721,6 +752,41 @@ font-weight: 600; } +/* Loss list — what the customer is giving up. Rendered when the confirm + step config includes `losses`. */ +.ck-cancel-flow .ck-loss-block { + margin-bottom: 20px; +} + +.ck-cancel-flow .ck-loss-label { + font-size: 13px; + font-weight: 500; + color: var(--ck-color-text-secondary); + margin-bottom: 8px; +} + +.ck-cancel-flow .ck-loss-list { + list-style: none; + padding: 0; + margin: 0; +} + +.ck-cancel-flow .ck-loss-item { + display: flex; + align-items: baseline; + gap: 10px; + padding: 3px 0; + font-size: 14.5px; + color: var(--ck-color-text); +} + +.ck-cancel-flow .ck-loss-bullet { + color: var(--ck-color-text-muted); + font-size: 8px; + line-height: 1; + transform: translateY(-2px); +} + /* --- Success step --- */ /* No box-padding here — .ck-content already supplies the surrounding */ /* inset. The 8px top compensates for the missing title row above the */ @@ -738,8 +804,9 @@ width: 56px; height: 56px; border-radius: 999px; - background: var(--ck-color-success-soft); - color: var(--ck-color-success); + /* Sand disc, ink check — the check is the success cue, the disc is just elevation. */ + background: var(--ck-color-surface-muted); + color: var(--ck-color-text); margin-bottom: 20px; }