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
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()
+ })
+})