From 78d589c79224dfb73a823ee01b5ca25c035c47e3 Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Tue, 14 Jul 2026 11:46:14 -0400 Subject: [PATCH] Add catalog overrides for offer accept and decline CTAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer report: the rebate accept button ("Accept refund") had no override path anywhere — offer cta/declineCta are synthesized server-side defaults, the flow builder has no CTA fields, and the chrome catalog deliberately excluded offer copy. The embed's rebateAccept key covered exactly this. New offer.acceptCta. keys and a shared offer.declineCta, all with empty defaults: a set value replaces the offer's copy, an unset key falls through — so when authored per-offer CTAs exist someday, they keep winning over unset keys. Rides the existing override pipeline, so the dashboard rows and org-level delivery come for free. --- .../steps/offer/default-contact-offer.tsx | 4 +- .../steps/offer/default-discount-offer.tsx | 4 +- .../steps/offer/default-pause-offer.tsx | 4 +- .../steps/offer/default-plan-change-offer.tsx | 4 +- .../steps/offer/default-rebate-offer.tsx | 4 +- .../steps/offer/default-redirect-offer.tsx | 8 ++-- .../offer/default-trial-extension-offer.tsx | 4 +- packages/react/src/core/messages.ts | 28 +++++++++++++ .../tests/components/cancel-flow.test.tsx | 40 +++++++++++++++++++ 9 files changed, 85 insertions(+), 15 deletions(-) diff --git a/packages/react/src/components/steps/offer/default-contact-offer.tsx b/packages/react/src/components/steps/offer/default-contact-offer.tsx index 38d3c77..45987a6 100644 --- a/packages/react/src/components/steps/offer/default-contact-offer.tsx +++ b/packages/react/src/components/steps/offer/default-contact-offer.tsx @@ -33,10 +33,10 @@ export function DefaultContactOffer({ onClick={() => onAccept()} disabled={isProcessing} > - {isProcessing ? msg.common.processing : offer.copy.cta} + {isProcessing ? msg.common.processing : msg.offer.acceptCta.contact || offer.copy.cta} diff --git a/packages/react/src/components/steps/offer/default-discount-offer.tsx b/packages/react/src/components/steps/offer/default-discount-offer.tsx index 81bdccd..ca2397a 100644 --- a/packages/react/src/components/steps/offer/default-discount-offer.tsx +++ b/packages/react/src/components/steps/offer/default-discount-offer.tsx @@ -41,10 +41,10 @@ export function DefaultDiscountOffer({ onClick={() => onAccept()} disabled={isProcessing} > - {isProcessing ? msg.common.processing : offer.copy.cta} + {isProcessing ? msg.common.processing : msg.offer.acceptCta.discount || offer.copy.cta} 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 f87eaaf..e2f95b1 100644 --- a/packages/react/src/components/steps/offer/default-pause-offer.tsx +++ b/packages/react/src/components/steps/offer/default-pause-offer.tsx @@ -57,10 +57,10 @@ export function DefaultPauseOffer({ onClick={() => onAccept({ months })} disabled={isProcessing} > - {isProcessing ? msg.common.processing : offer.copy.cta} + {isProcessing ? msg.common.processing : msg.offer.acceptCta.pause || offer.copy.cta} ) diff --git a/packages/react/src/components/steps/offer/default-plan-change-offer.tsx b/packages/react/src/components/steps/offer/default-plan-change-offer.tsx index 7362b62..c170131 100644 --- a/packages/react/src/components/steps/offer/default-plan-change-offer.tsx +++ b/packages/react/src/components/steps/offer/default-plan-change-offer.tsx @@ -33,7 +33,7 @@ export function DefaultPlanChangeOffer({ ? msg.common.processing : selectedPlan?.name ? formatMessage(msg.offer.switchToCta, { planName: selectedPlan.name }) - : offer.copy.cta + : msg.offer.acceptCta.plan_change || offer.copy.cta return (
@@ -98,7 +98,7 @@ export function DefaultPlanChangeOffer({ {ctaLabel}
diff --git a/packages/react/src/components/steps/offer/default-rebate-offer.tsx b/packages/react/src/components/steps/offer/default-rebate-offer.tsx index 03f044e..7575528 100644 --- a/packages/react/src/components/steps/offer/default-rebate-offer.tsx +++ b/packages/react/src/components/steps/offer/default-rebate-offer.tsx @@ -73,10 +73,10 @@ export function DefaultRebateOffer({ onClick={() => onAccept()} disabled={isProcessing} > - {isProcessing ? msg.common.processing : offer.copy.cta} + {isProcessing ? msg.common.processing : msg.offer.acceptCta.rebate || offer.copy.cta} diff --git a/packages/react/src/components/steps/offer/default-redirect-offer.tsx b/packages/react/src/components/steps/offer/default-redirect-offer.tsx index 5f5d0d4..81436d9 100644 --- a/packages/react/src/components/steps/offer/default-redirect-offer.tsx +++ b/packages/react/src/components/steps/offer/default-redirect-offer.tsx @@ -1,8 +1,10 @@ +import { defaultMessages } from '../../../core/messages' import type { OfferDecision, OfferStepProps } from '../../../core/types' import { cn } from '../../../core/utils' import { RichText } from '../../rich-text' -export function DefaultRedirectOffer({ title, description, offer, onDecline, classNames }: OfferStepProps) { +export function DefaultRedirectOffer({ title, description, offer, onDecline, classNames, messages }: OfferStepProps) { + const msg = messages ?? defaultMessages const url = (offer as OfferDecision & { url: string }).url const headline = title ?? offer.copy.headline const body = description ?? offer.copy.body @@ -15,12 +17,12 @@ export function DefaultRedirectOffer({ title, description, offer, onDecline, cla
- {offer.copy.cta} + {msg.offer.acceptCta.redirect || offer.copy.cta}
diff --git a/packages/react/src/components/steps/offer/default-trial-extension-offer.tsx b/packages/react/src/components/steps/offer/default-trial-extension-offer.tsx index 592f088..ddf17b7 100644 --- a/packages/react/src/components/steps/offer/default-trial-extension-offer.tsx +++ b/packages/react/src/components/steps/offer/default-trial-extension-offer.tsx @@ -44,10 +44,10 @@ export function DefaultTrialExtensionOffer({ onClick={() => onAccept()} disabled={isProcessing} > - {isProcessing ? msg.common.processing : offer.copy.cta} + {isProcessing ? msg.common.processing : msg.offer.acceptCta.trial_extension || offer.copy.cta} diff --git a/packages/react/src/core/messages.ts b/packages/react/src/core/messages.ts index 0a4e79a..437f6ca 100644 --- a/packages/react/src/core/messages.ts +++ b/packages/react/src/core/messages.ts @@ -93,6 +93,24 @@ export interface CancelFlowMessages { currentPlanBadge: string /** CTA when a plan is selected in a plan-change offer. Supports `{planName}`. */ switchToCta: string + /** + * Accept-button override per offer type. Defaults are empty: an unset + * key falls through to the offer's own `copy.cta` (today a + * server-synthesized default like "Accept refund"; authored per-offer + * copy, when it exists, keeps winning over an unset key). + */ + acceptCta: { + discount: string + pause: string + trial_extension: string + plan_change: string + contact: string + redirect: string + rebate: string + } + /** Decline-link override for every offer type. Empty default falls + * through to the offer's own `copy.declineCta`. */ + declineCta: string rebate: { paidLabel: string moneyBackLabel: string @@ -186,6 +204,16 @@ export const defaultMessages: CancelFlowMessages = { newEndDateLabel: 'New end date', currentPlanBadge: 'Current', switchToCta: 'Switch to {planName}', + acceptCta: { + discount: '', + pause: '', + trial_extension: '', + plan_change: '', + contact: '', + redirect: '', + rebate: '', + }, + declineCta: '', rebate: { paidLabel: 'You paid this period', moneyBackLabel: 'Money back', diff --git a/packages/react/tests/components/cancel-flow.test.tsx b/packages/react/tests/components/cancel-flow.test.tsx index 3438dd1..8b356fc 100644 --- a/packages/react/tests/components/cancel-flow.test.tsx +++ b/packages/react/tests/components/cancel-flow.test.tsx @@ -644,3 +644,43 @@ describe('per-offer success copy', () => { expect(screen.getByText("We're so happy you're still here.")).toBeInTheDocument() }) }) + +describe('offer CTA catalog overrides', () => { + const rebateSteps: Step[] = [ + { + type: 'survey', + reasons: [ + { + id: 'guarantee', + label: 'Money-back window', + offer: { type: 'rebate', amountMinor: 500, currency: 'USD' }, + }, + ], + }, + { type: 'confirm' }, + ] + + it('acceptCta and declineCta override the offer copy when set', async () => { + const user = userEvent.setup() + renderFlow({ + steps: rebateSteps, + i18n: { + messages: { en: { offer: { acceptCta: { rebate: 'Claim my refund' }, declineCta: 'Keep my plan' } } }, + }, + }) + await user.click(screen.getByText('Money-back window')) + await user.click(screen.getByText('Continue')) + expect(await screen.findByText('Claim my refund')).toBeInTheDocument() + expect(screen.getByText('Keep my plan')).toBeInTheDocument() + expect(screen.queryByText('Accept refund')).not.toBeInTheDocument() + }) + + it('unset keys fall through to the offer copy', async () => { + const user = userEvent.setup() + renderFlow({ steps: rebateSteps }) + await user.click(screen.getByText('Money-back window')) + await user.click(screen.getByText('Continue')) + expect(await screen.findByText('Accept refund')).toBeInTheDocument() + expect(screen.getByText('No thanks')).toBeInTheDocument() + }) +})