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')} />