From e51fbe72de062cb2f54e5033ab93bee5072d8d5c Mon Sep 17 00:00:00 2001 From: Sag Date: Tue, 17 Feb 2026 08:09:40 +0100 Subject: [PATCH 1/4] Archived all other retention offers on creation/activation ref https://linear.app/ghost/issue/BER-3325 - as we allow for one retention offer per cadence ("Monthly retention", "Yearly retention"), we want to keep at most 1 active retention offer per cadence - when a retention offer is created, it automatically archives existing retention offers on that cadence - similarly, when a retention offer is activated from the settings UI (e.g. enable "Monthly retention"), all other retention offers on that cadence are archived ) --- .../services/offers/application/offers-api.js | 39 +++++++ ghost/core/test/e2e-api/admin/offers.test.js | 106 ++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/ghost/core/core/server/services/offers/application/offers-api.js b/ghost/core/core/server/services/offers/application/offers-api.js index 6ca5d454ce6..b393d99d11d 100644 --- a/ghost/core/core/server/services/offers/application/offers-api.js +++ b/ghost/core/core/server/services/offers/application/offers-api.js @@ -23,6 +23,29 @@ class OffersAPI { this.repository = repository; } + /** + * Archives all previous retention offers on a given cadence. + * As retention offers exist per cadence ("Monthly retention", "Yearly retention"), we allow for at most 1 active retention offer per cadence. + * @param {string} offerId + * @param {'month'|'year'} cadence + * @param {Object} [options] + */ + async archiveActiveRetentionOffers(offerId, cadence, options = {}) { + const activeRetentionOffers = await this.repository.getAll({ + transacting: options.transacting, + filter: 'status:active+redemption_type:retention' + }); + + for (const activeRetentionOffer of activeRetentionOffers) { + if (activeRetentionOffer.id === offerId || activeRetentionOffer.cadence.value !== cadence) { + continue; + } + + activeRetentionOffer.status = OfferStatus.create('archived'); + await this.repository.save(activeRetentionOffer, options); + } + } + /** * @param {object} data * @param {string} data.id @@ -62,6 +85,14 @@ class OffersAPI { await this.repository.save(offer, saveOptions); + if (offer.redemptionType.value === 'retention' && offer.status.value === 'active') { + await this.archiveActiveRetentionOffers( + offer.id, + offer.cadence.value, + saveOptions + ); + } + return OfferMapper.toDTO(offer); }); } @@ -116,6 +147,14 @@ class OffersAPI { await this.repository.save(offer, updateOptions); + if (offer.redemptionType.value === 'retention' && offer.status.value === 'active') { + await this.archiveActiveRetentionOffers( + offer.id, + offer.cadence.value, + updateOptions + ); + } + return OfferMapper.toDTO(offer); }); } diff --git a/ghost/core/test/e2e-api/admin/offers.test.js b/ghost/core/test/e2e-api/admin/offers.test.js index 1cc977b2b4a..30d2e3684f6 100644 --- a/ghost/core/test/e2e-api/admin/offers.test.js +++ b/ghost/core/test/e2e-api/admin/offers.test.js @@ -811,4 +811,110 @@ describe('Offers API', function () { assert.equal(body.offers[0].tier.id, defaultTier.id); }); }); + + it('Keeps one active retention offer per cadence on create', async function () { + const suffix = Date.now().toString(16).slice(-6); + + const firstOffer = { + name: `Yearly retention one ${suffix}`, + code: `yearly-retention-${suffix}-1`, + display_title: '', + display_description: '', + type: 'percent', + cadence: 'year', + amount: 20, + duration: 'once', + duration_in_months: null, + currency_restriction: false, + currency: null, + status: 'active', + redemption_type: 'retention', + tier: null + }; + + const secondOffer = { + ...firstOffer, + name: `Yearly retention two ${suffix}`, + code: `yearly-retention-${suffix}-2`, + amount: 25 + }; + + const firstCreateResponse = await agent + .post('offers/') + .body({offers: [firstOffer]}) + .expectStatus(200); + const firstOfferId = firstCreateResponse.body.offers[0].id; + + const secondCreateResponse = await agent + .post('offers/') + .body({offers: [secondOffer]}) + .expectStatus(200); + const secondOfferId = secondCreateResponse.body.offers[0].id; + + const firstReadResponse = await agent + .get(`offers/${firstOfferId}/`) + .expectStatus(200); + const secondReadResponse = await agent + .get(`offers/${secondOfferId}/`) + .expectStatus(200); + + assert.equal(firstReadResponse.body.offers[0].status, 'archived'); + assert.equal(secondReadResponse.body.offers[0].status, 'active'); + }); + + it('Keeps one active retention offer per cadence on activate', async function () { + const suffix = (Date.now() + 1).toString(16).slice(-6); + + const activeOffer = { + name: `Yearly retention active ${suffix}`, + code: `yearly-retention-${suffix}-active`, + display_title: '', + display_description: '', + type: 'percent', + cadence: 'year', + amount: 20, + duration: 'once', + duration_in_months: null, + currency_restriction: false, + currency: null, + status: 'active', + redemption_type: 'retention', + tier: null + }; + + const archivedOffer = { + ...activeOffer, + name: `Yearly retention archived ${suffix}`, + code: `yearly-retention-${suffix}-archived`, + status: 'archived', + amount: 30 + }; + + const activeCreateResponse = await agent + .post('offers/') + .body({offers: [activeOffer]}) + .expectStatus(200); + const activeOfferId = activeCreateResponse.body.offers[0].id; + + const archivedCreateResponse = await agent + .post('offers/') + .body({offers: [archivedOffer]}) + .expectStatus(200); + const archivedOfferId = archivedCreateResponse.body.offers[0].id; + + await agent + .put(`offers/${archivedOfferId}/`) + .body({offers: [{status: 'active'}]}) + .expectStatus(200); + + const activeReadResponse = await agent + .get(`offers/${activeOfferId}/`) + .expectStatus(200); + const archivedReadResponse = await agent + .get(`offers/${archivedOfferId}/`) + .expectStatus(200); + + assert.equal(activeReadResponse.body.offers[0].status, 'archived'); + assert.equal(archivedReadResponse.body.offers[0].status, 'active'); + }); }); From f21f8ecae74a4b6b2bd1923a7e2c0bffd02ba7ba Mon Sep 17 00:00:00 2001 From: Sag Date: Tue, 17 Feb 2026 08:18:54 +0100 Subject: [PATCH 2/4] Updated Portal Preview for retention offers ref https://linear.app/ghost/issue/BER-3325 - Portal Preview now supports Retention offers, with configurable title / description --- apps/portal/src/app.js | 16 +++ .../src/components/pages/account-plan-page.js | 77 ++++++++++++--- apps/portal/test/app.test.js | 20 ++++ .../pages/account-plan-page.test.js | 99 +++++++++++++++++-- 4 files changed, 189 insertions(+), 23 deletions(-) diff --git a/apps/portal/src/app.js b/apps/portal/src/app.js index 80960853f08..9c90b3ee979 100644 --- a/apps/portal/src/app.js +++ b/apps/portal/src/app.js @@ -335,8 +335,24 @@ export default class App extends React.Component { data.tier = { id: value || Fixtures.offer.tier.id }; + } else if (key === 'redemption_type') { + data.redemption_type = value || 'signup'; } } + + if (data.redemption_type === 'retention') { + const previewSubscriptionId = Fixtures.member.preview?.subscriptions?.[0]?.id; + + return { + page: 'accountPlan', + offers: [data], + pageData: { + action: 'cancel', + subscriptionId: previewSubscriptionId + } + }; + } + return { page: 'offer', pageData: data diff --git a/apps/portal/src/components/pages/account-plan-page.js b/apps/portal/src/components/pages/account-plan-page.js index 8229a4ee235..4a6ae99fae0 100644 --- a/apps/portal/src/components/pages/account-plan-page.js +++ b/apps/portal/src/components/pages/account-plan-page.js @@ -51,7 +51,7 @@ export const AccountPlanPageStyles = ` } `; -function getConfirmationPageTitle({confirmationType}) { +function getConfirmationPageTitle({confirmationType, pendingOffer}) { if (confirmationType === 'changePlan') { return t('Confirm subscription'); } else if (confirmationType === 'cancel') { @@ -59,15 +59,15 @@ function getConfirmationPageTitle({confirmationType}) { } else if (confirmationType === 'subscribe') { return t('Subscribe'); } else if (confirmationType === 'offerRetention') { - return 'Before you go'; + return pendingOffer?.display_title || 'Before you go'; } } -const Header = ({showConfirmation, confirmationType}) => { +const Header = ({showConfirmation, confirmationType, pendingOffer}) => { const {member} = useContext(AppContext); let title = isPaidMember({member}) ? t('Change plan') : t('Choose a plan'); if (showConfirmation) { - title = getConfirmationPageTitle({confirmationType}); + title = getConfirmationPageTitle({confirmationType, pendingOffer}); } return (
@@ -279,6 +279,7 @@ function getOfferMessage(offer, originalPrice, currency, amountOff) { return ''; } +// TODO: Add i18n once copy is finalized const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineOffer}) => { const {brandColor, action} = useContext(AppContext); const isAcceptingOffer = action === 'applyOffer:running'; @@ -288,6 +289,9 @@ const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineO const discountedPrice = formatNumber(getUpdatedOfferPrice({offer, price})); const amountOff = getOfferOffAmount({offer}); const discountText = offer.type === 'free_months' ? `${amountOff} free` : `${amountOff} off`; + const cadenceLabel = offer.cadence === 'month' ? 'Monthly' : 'Yearly'; + const productCadenceLabel = `${product.name} - ${cadenceLabel}`; + const displayDescription = offer.display_description || 'We\'d hate to see you go! How about a special offer to stay?'; const offerMessage = getOfferMessage(offer, originalPrice, currency, amountOff); @@ -295,12 +299,12 @@ const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineO return (

- {'We\'d hate to see you go! How about a special offer to stay?'} + {displayDescription}

-

{product.name} - {offer.cadence === 'month' ? 'Monthly' : 'Yearly'}

+

{productCadenceLabel}

{discountText}
@@ -323,6 +327,7 @@ const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineO

+ {/* TODO: Add i18n once copy is finalized */}
+ {/* TODO: Add i18n once copy is finalized */} offer.redemption_type === 'retention') || null; + const nextRetentionOfferSignature = this.getRetentionOfferSignature(nextRetentionOffer); + const currentRetentionOfferSignature = this.getRetentionOfferSignature(this.state.pendingOffer); + + const shouldRefreshRetentionFlow = this.state.targetSubscriptionId !== pageData.subscriptionId || + this.state.confirmationType !== 'offerRetention' || + nextRetentionOfferSignature !== currentRetentionOfferSignature; + + if (shouldRefreshRetentionFlow) { + this.onCancelSubscription({subscriptionId: pageData.subscriptionId}); + } + + // Clear action so normal navigation doesn't continuously re-trigger + pageData.action = null; + } + getInitialState() { const {member, site} = this.context; @@ -662,6 +710,7 @@ export default class AccountPlanPage extends React.Component {
this.onBack(e)} confirmationType={confirmationType} + pendingOffer={pendingOffer} showConfirmation={showConfirmation} /> { + const app = new App({siteUrl: 'http://example.com'}); + const previewData = app.fetchOfferQueryStrData('redemption_type=retention&display_title=Before%2520you%2520go&display_description=Please%2520stay&type=free_months&amount=2&cadence=month&tier_id=product_123&enabled=false'); + + expect(previewData.page).toBe('accountPlan'); + expect(previewData.pageData).toMatchObject({ + action: 'cancel' + }); + expect(previewData.offers).toHaveLength(1); + expect(previewData.offers[0]).toMatchObject({ + display_title: 'Before you go', + display_description: 'Please stay', + redemption_type: 'retention', + type: 'free_months', + amount: 2, + cadence: 'month' + }); + expect(previewData.offers[0].tier).toMatchObject({id: 'product_123'}); + }); }); diff --git a/apps/portal/test/unit/components/pages/account-plan-page.test.js b/apps/portal/test/unit/components/pages/account-plan-page.test.js index 24e8225d57e..f1828f2c3d9 100644 --- a/apps/portal/test/unit/components/pages/account-plan-page.test.js +++ b/apps/portal/test/unit/components/pages/account-plan-page.test.js @@ -101,10 +101,30 @@ describe('Account Plan Page', () => { expect(confirmCancelButton).toBeInTheDocument(); }); - test('shows retention offer when opened with cancel pageData and retention offers exist', () => { - const overrides = generateAccountPlanFixture(); - const subscriptionId = overrides.member.subscriptions[0].id; - const paidProduct = overrides.site.products.find(p => p.type === 'paid'); + test('shows retention offer when opened with cancel pageData and retention offers exist', async () => { + const paidProduct = getProductData({ + name: 'Basic', + monthlyPrice: getPriceData({interval: 'month', amount: 1000, currency: 'usd'}), + yearlyPrice: getPriceData({interval: 'year', amount: 10000, currency: 'usd'}) + }); + const products = [paidProduct, getProductData({type: 'free'})]; + const site = getSiteData({ + products, + portalProducts: [paidProduct.id] + }); + const member = getMemberData({ + paid: true, + subscriptions: [ + getSubscriptionData({ + status: 'active', + interval: 'month', + amount: paidProduct.monthlyPrice.amount, + currency: 'USD', + priceId: paidProduct.monthlyPrice.id + }) + ] + }); + const subscriptionId = member.subscriptions[0].id; const retentionOffer = getOfferData({ redemptionType: 'retention', name: 'Stay with us', @@ -114,15 +134,76 @@ describe('Account Plan Page', () => { tierId: paidProduct.id, tierName: paidProduct.name }); - const {queryByText} = customSetup({ - ...overrides, + const {findByRole} = customSetup({ + site, + member, offers: [retentionOffer], pageData: {action: 'cancel', subscriptionId} }); - // Should show retention offer message instead of cancellation confirmation - const offerMessage = queryByText(/We'd hate to see you go/i); - expect(offerMessage).toBeInTheDocument(); + // Should show retention offer section instead of cancellation confirmation + const acceptOfferButton = await findByRole('button', {name: 'Continue subscription'}); + expect(acceptOfferButton).toBeInTheDocument(); + }); + + test('refreshes retention preview details when offer context changes', () => { + const paidProduct = getProductData({ + name: 'Basic', + monthlyPrice: getPriceData({interval: 'month', amount: 1000, currency: 'usd'}), + yearlyPrice: getPriceData({interval: 'year', amount: 10000, currency: 'usd'}) + }); + const products = [paidProduct, getProductData({type: 'free'})]; + const site = getSiteData({ + products, + portalProducts: [paidProduct.id] + }); + const member = getMemberData({ + paid: true, + subscriptions: [ + getSubscriptionData({ + status: 'active', + interval: 'month', + amount: paidProduct.monthlyPrice.amount, + currency: 'USD', + priceId: paidProduct.monthlyPrice.id + }) + ] + }); + const subscriptionId = member.subscriptions[0].id; + + const firstOffer = getOfferData({ + redemptionType: 'retention', + displayTitle: 'First retention title', + displayDescription: 'First retention description', + type: 'percent', + amount: 10, + cadence: 'month', + tierId: paidProduct.id, + tierName: paidProduct.name + }); + const secondOffer = { + ...firstOffer, + amount: 25, + display_title: 'Second retention title', + display_description: 'Second retention description' + }; + + const {queryByText, context, rerender} = customSetup({ + site, + member, + offers: [firstOffer], + pageData: {action: 'cancel', subscriptionId} + }); + + expect(queryByText('First retention title')).toBeInTheDocument(); + expect(queryByText('10% off')).toBeInTheDocument(); + + context.offers = [secondOffer]; + context.pageData = {action: 'cancel', subscriptionId}; + rerender(); + + expect(queryByText('Second retention title')).toBeInTheDocument(); + expect(queryByText('25% off')).toBeInTheDocument(); }); test('clears pageData after triggering cancellation so it does not re-trigger on remount', () => { From 5698f8abf2dd8deb939557962cb6289e62f847c6 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 18 Feb 2026 10:02:25 +0100 Subject: [PATCH 3/4] Released @tryghost/portal v2.64.4 Changelog for v2.64.3 -> 2.64.4: - https://github.com/TryGhost/Ghost/commit/eef0d56b73 --- apps/portal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/package.json b/apps/portal/package.json index 93a628ef7ce..aca281ed1f0 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.64.3", + "version": "2.64.4", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", From 2b4fe473b1e21f0246a5f805ae3688aa08a6b2ff Mon Sep 17 00:00:00 2001 From: Sag Date: Tue, 17 Feb 2026 08:20:45 +0100 Subject: [PATCH 4/4] Wired up Retention offers settings ref https://linear.app/ghost/issue/BER-3325 - Wired up Portal preview, offer redemption count, list and edit views, on/off toggle in Retention Offer settings - As we have a single retention offer per cadence in the UI ("Monthly retention", "Yearly retention"), updating an existing retention offer requires additional logic: - if the billing terms (amount, duration, type) have changed, we create a new active retention offer and archive existing ones. We cannot update an existing offer's billing terms once created, as the offer may have been redeemed already by members. - if only the display title/description have changed (no billing changes), then we can edit the existing offer --- apps/admin-x-framework/src/api/offers.ts | 9 + .../growth/offers/add-offer-modal.tsx | 3 +- .../growth/offers/edit-offer-modal.tsx | 3 +- .../offers/edit-retention-offer-modal.tsx | 461 +++++++++++++++-- .../growth/offers/offers-retention.tsx | 112 +++- .../utils/get-offers-portal-preview-url.ts | 5 +- .../test/acceptance/membership/offers.test.ts | 481 +++++++++++++++++- 7 files changed, 1009 insertions(+), 65 deletions(-) diff --git a/apps/admin-x-framework/src/api/offers.ts b/apps/admin-x-framework/src/api/offers.ts index 922b5294a59..ba0124be661 100644 --- a/apps/admin-x-framework/src/api/offers.ts +++ b/apps/admin-x-framework/src/api/offers.ts @@ -1,5 +1,6 @@ import {Meta, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks'; import {updateQueryCache, insertToQueryCache} from '../utils/api/update-queries'; +import {useQueryClient} from '@tanstack/react-query'; export type Offer = { id: string; @@ -44,6 +45,14 @@ export interface OfferAddResponseType { const dataType = 'OffersResponseType'; +export const useInvalidateOffers = () => { + const queryClient = useQueryClient(); + + return () => { + return queryClient.invalidateQueries([dataType]); + }; +}; + export const useBrowseOffers = createQuery({ dataType, path: '/offers/', diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx index f17ea40cf46..923e73d3f99 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx @@ -579,7 +579,8 @@ const AddOfferModal = () => { durationInMonths: formState.durationInMonths || 0, currency: formState.currency || 'USD', status: formState.status || 'active', - tierId: formState.tierId || activeTiers[0]?.id + tierId: formState.tierId || activeTiers[0]?.id, + redemptionType: 'signup' }; }, [formState, activeTiers]); diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsx index 45897ad1680..695347f0fe9 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsx @@ -243,7 +243,8 @@ const EditOfferModal: React.FC<{id: string}> = ({id}) => { durationInMonths: formState?.duration_in_months || 0, currency: formState?.currency || '', status: formState?.status || '', - tierId: formState?.tier?.id || '' + tierId: formState?.tier?.id || '', + redemptionType: 'signup' }; const newHref = getOfferPortalPreviewUrl(dataset, siteData.url); diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx index 6ed78e5a6bd..9caf92aee94 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx @@ -1,13 +1,21 @@ +import PortalFrame from '../../membership/portal/portal-frame'; +import toast from 'react-hot-toast'; import {ButtonSelect, type OfferType} from './add-offer-modal'; -import {Form, PreviewModalContent, Select, type SelectOption, TextArea, TextField, Toggle} from '@tryghost/admin-x-design-system'; -import {useForm} from '@tryghost/admin-x-framework/hooks'; +import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; +import {Form, PreviewModalContent, Select, type SelectOption, TextArea, TextField, Toggle, showToast} from '@tryghost/admin-x-design-system'; +import {JSONError} from '@tryghost/admin-x-framework/errors'; +import {type Offer, useAddOffer, useBrowseOffers, useEditOffer, useInvalidateOffers} from '@tryghost/admin-x-framework/api/offers'; +import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '../../../../utils/get-offers-portal-preview-url'; +import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; +import {useEffect, useMemo, useState} from 'react'; +import {useGlobalData} from '../../../providers/global-data-provider'; import {useRouting} from '@tryghost/admin-x-framework/routing'; type RetentionOfferFormState = { enabled: boolean; displayTitle: string; displayDescription: string; - type: 'percent' | 'trial'; + type: 'percent' | 'free_months'; percentAmount: number; duration: string; durationInMonths: number; @@ -25,36 +33,155 @@ const durationOptions: SelectOption[] = [ {value: 'forever', label: 'Forever'} ]; -const getDefaultState = (id: string): RetentionOfferFormState => { - if (id === 'monthly') { - return { - enabled: true, - displayTitle: '', - displayDescription: '', - type: 'percent', - percentAmount: 20, - duration: 'once', - durationInMonths: 1, - freeMonths: 1 - }; +const MAX_PERCENT_AMOUNT = 100; + +type RetentionOfferTerms = { + type: 'percent' | 'free_months'; + amount: number; + duration: string; + durationInMonths: number; +}; + +const getResolvedAmount = ({ + type, + percentAmount, + freeMonths, + lastPercentAmount, + lastFreeMonths +}: { + type: 'percent' | 'free_months'; + percentAmount: number; + freeMonths: number; + lastPercentAmount: number; + lastFreeMonths: number; +}) => { + if (type === 'free_months') { + return freeMonths > 0 ? freeMonths : lastFreeMonths; } + + return percentAmount > 0 ? percentAmount : lastPercentAmount; +}; + +const getDefaultState = (): RetentionOfferFormState => { return { enabled: false, displayTitle: '', displayDescription: '', - type: 'percent', - percentAmount: 0, + type: 'free_months', + percentAmount: 20, duration: 'once', durationInMonths: 1, freeMonths: 1 }; }; +const getRetentionOfferFormState = (offer: Offer | null): RetentionOfferFormState => { + const defaultState = getDefaultState(); + + if (!offer) { + return defaultState; + } + + const isPercentOffer = offer.type === 'percent'; + const isFreeMonthsOffer = offer.type === 'free_months'; + const repeatingDurationInMonths = offer.duration === 'repeating' && offer.duration_in_months ? offer.duration_in_months : defaultState.durationInMonths; + + return { + enabled: offer.status === 'active', + displayTitle: offer.display_title || '', + displayDescription: offer.display_description || '', + type: isFreeMonthsOffer ? 'free_months' : 'percent', + percentAmount: isPercentOffer ? offer.amount : defaultState.percentAmount, + duration: isPercentOffer ? offer.duration : defaultState.duration, + durationInMonths: repeatingDurationInMonths, + freeMonths: isFreeMonthsOffer ? offer.amount : defaultState.freeMonths + }; +}; + +const getFormOfferTerms = ({ + formState, + lastPercentAmount, + lastFreeMonths +}: { + formState: RetentionOfferFormState; + lastPercentAmount: number; + lastFreeMonths: number; +}): RetentionOfferTerms => { + const amount = getResolvedAmount({ + type: formState.type, + percentAmount: formState.percentAmount, + freeMonths: formState.freeMonths, + lastPercentAmount, + lastFreeMonths + }); + + if (formState.type === 'free_months') { + return { + type: 'free_months', + amount, + duration: 'free_months', + durationInMonths: 0 + }; + } + + const duration = formState.duration; + const durationInMonths = duration === 'repeating' ? Math.max(1, formState.durationInMonths) : 0; + + return { + type: 'percent', + amount, + duration, + durationInMonths + }; +}; + +const getOfferTerms = (offer: Offer | null): RetentionOfferTerms | null => { + if (!offer) { + return null; + } + + const type = offer.type === 'free_months' ? 'free_months' : 'percent'; + const duration = type === 'free_months' ? 'free_months' : offer.duration; + const durationInMonths = duration === 'repeating' ? offer.duration_in_months || 0 : 0; + + return { + type, + amount: offer.amount, + duration, + durationInMonths + }; +}; + +const getTermsSignature = (terms: RetentionOfferTerms | null): string => { + if (!terms) { + return ''; + } + + return `${terms.type}:${terms.amount}:${terms.duration}:${terms.durationInMonths}`; +}; + +const hasFormChangesFromDefault = (formState: RetentionOfferFormState, defaultState: RetentionOfferFormState): boolean => { + return formState.displayTitle !== defaultState.displayTitle || + formState.displayDescription !== defaultState.displayDescription || + formState.type !== defaultState.type || + formState.percentAmount !== defaultState.percentAmount || + formState.duration !== defaultState.duration || + formState.durationInMonths !== defaultState.durationInMonths || + formState.freeMonths !== defaultState.freeMonths; +}; + const RetentionOfferSidebar: React.FC<{ formState: RetentionOfferFormState; updateForm: (updater: (state: RetentionOfferFormState) => RetentionOfferFormState) => void; + clearError: (field: string) => void; + errors: ErrorMessages; cadence: 'monthly' | 'yearly'; -}> = ({formState, updateForm, cadence}) => { + redemptions: number; +}> = ({formState, updateForm, clearError, errors, cadence, redemptions}) => { + const availableDurationOptions = cadence === 'yearly' + ? durationOptions.filter(option => option.value !== 'repeating') + : durationOptions; + return (
@@ -62,12 +189,13 @@ const RetentionOfferSidebar: React.FC<{
Performance - 0 redemptions + {redemptions} redemptions
General
{ updateForm(state => ({...state, displayTitle: e.target.value})); }} + onKeyDown={() => clearError('displayTitle')} />