Ghost/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx

686 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import PortalFrame from '../../membership/portal/PortalFrame';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import {Button} from '@tryghost/admin-x-design-system';
import {ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks';
import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system';
import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site';
import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl';
import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers';
import {getTiersCadences} from '../../../../utils/getTiersCadences';
import {useAddOffer} from '@tryghost/admin-x-framework/api/offers';
import {useEffect, useMemo, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useModal} from '@ebay/nice-modal-react';
import {useRouting} from '@tryghost/admin-x-framework/routing';
// we should replace this with a library
function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-');
}
interface OfferType {
title: string;
description: string;
}
const ButtonSelect: React.FC<{type: OfferType, checked: boolean, onClick: () => void}> = ({type, checked, onClick}) => {
const checkboxClass = checked ? 'bg-black text-white' : 'border border-grey-300';
return (
<button className='text-left' type='button' onClick={onClick}>
<div className='flex gap-3'>
<div className={`mt-0.5 flex h-4 w-4 items-center justify-center rounded-full ${checkboxClass}`}>
{checked ? <Icon className='w-[7px] stroke-[4]' name='check' size='custom' /> : null}
</div>
<div className='flex flex-col'>
<span>{type.title}</span>
<span className='text-sm'>{type.description}</span>
</div>
</div>
</button>
);
};
type formStateTypes = {
disableBackground?: boolean;
name: string;
code: {
isDirty: boolean;
value: string;
};
displayTitle: {
isDirty: boolean;
value: string;
};
displayDescription: string;
type: string;
cadence: string;
amount: number;
duration: string;
durationInMonths: number;
currency: string;
status: string;
tierId: string;
fixedAmount?: number;
trialAmount?: number;
percentAmount?: number;
};
const calculateAmount = (formState: formStateTypes): number => {
const {fixedAmount = 0, percentAmount = 0, trialAmount = 0, amount = 0} = formState;
switch (formState.type) {
case 'fixed':
return fixedAmount * 100;
case 'percent':
return percentAmount;
case 'trial':
return trialAmount;
default:
return amount;
}
};
type SidebarProps = {
tierOptions: SelectOption[];
handleTierChange: (tier: SelectOption) => void;
selectedTier: SelectOption;
overrides: formStateTypes;
// handleTextInput: (e: React.ChangeEvent<HTMLInputElement>, key: keyof offerPortalPreviewUrlTypes) => void;
amountOptions: SelectOption[];
typeOptions: OfferType[];
durationOptions: SelectOption[];
handleTypeChange: (type: string) => void;
handleDurationChange: (duration: string) => void;
handleAmountTypeChange: (amountType: string) => void;
handleNameInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleTextAreaInput: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleDisplayTitleInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleAmountInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDurationInMonthsInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleCodeInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
validate: () => void;
clearError: (field: string) => void;
testId: string;
errors: ErrorMessages;
handleTrialAmountInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
const Sidebar: React.FC<SidebarProps> = ({tierOptions,
handleTierChange,
selectedTier,
// handleTextInput,
typeOptions,
durationOptions,
handleTypeChange,
handleDurationChange,
overrides,
handleAmountTypeChange,
handleNameInput,
handleTextAreaInput,
handleDisplayTitleInput,
handleDurationInMonthsInput,
handleAmountInput,
handleCodeInput,
clearError,
errors,
testId,
handleTrialAmountInput,
amountOptions}) => {
// const handleError = useHandleError();
const getFilteredDurationOptions = () => {
// Check if the selected tier's cadence is 'yearly'
if (selectedTier?.label?.includes('Yearly')) {
// Filter out 'repeating' from duration options
return durationOptions.filter(option => option.value !== 'repeating');
}
return durationOptions;
};
const filteredDurationOptions = getFilteredDurationOptions();
const [nameLength, setNameLength] = useState(0);
const nameLengthColor = nameLength > 40 ? 'text-red' : 'text-green';
const {siteData} = useGlobalData();
const [isCopied, setIsCopied] = useState(false);
const homepageUrl = getHomepageUrl(siteData!);
const offerUrl = `${homepageUrl}${overrides.code.value}`;
const handleCopyClick = async () => {
await navigator.clipboard.writeText(offerUrl);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div className='pt-7' data-testId={testId}>
<Form>
<section>
<h2 className='mb-4 text-lg'>General</h2>
<div className='flex flex-col gap-6'>
<TextField
error={Boolean(errors.name)}
hint={errors.name || <div className='flex justify-between'><span>Visible to members on Stripe Checkout page</span><strong><span className={`${nameLengthColor}`}>{nameLength}</span> / 40</strong></div>}
maxLength={40}
placeholder='Black Friday'
title='Offer name'
onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.name = 'Name is required';
}
}}
onChange={(e) => {
handleNameInput(e);
setNameLength(e.target.value.length);
}}
onKeyDown={() => clearError('name')}
/>
<TextField
error={Boolean(errors.displayTitle)}
hint={errors.displayTitle}
placeholder='Black Friday Special'
title='Display title'
value={overrides.displayTitle.value}
onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.displayTitle = 'Display title is required';
}
}}
onChange={(e) => {
handleDisplayTitleInput(e);
}}
onKeyDown={() => clearError('displayTitle')}
/>
<TextArea
placeholder='Take advantage of this limited-time offer.'
title='Display description'
value={overrides.displayDescription}
onChange={(e) => {
handleTextAreaInput(e);
}}
/>
</div>
</section>
<section className='mt-4'>
<h2 className='mb-4 text-lg'>Details</h2>
<div className='flex flex-col gap-6'>
<div className='flex flex-col gap-4 rounded-md border border-grey-200 p-4'>
<ButtonSelect checked={overrides.type !== 'trial' ? true : false} type={typeOptions[0]} onClick={() => {
handleTypeChange('percent');
}} />
<ButtonSelect checked={overrides.type === 'trial' ? true : false} type={typeOptions[1]} onClick={() => {
handleTypeChange('trial');
}} />
</div>
<Select
options={tierOptions}
selectedOption={selectedTier}
title='Tier — Cadence'
onSelect={(e) => {
if (e) {
handleTierChange(e);
}
}}
/>
{
overrides.type !== 'trial' && <> <div className='relative'>
<TextField
error={Boolean(errors.amount)}
hint={errors.amount}
title='Amount off'
type='number'
value={
overrides.type === 'fixed'
? (overrides.fixedAmount === 0 ? '' : overrides.fixedAmount?.toString())
: (overrides.percentAmount === 0 ? '' : overrides.percentAmount?.toString())
}
onBlur={() => {
if (overrides.type === 'percent' && overrides.percentAmount === 0) {
errors.amount = 'Enter an amount greater than 0.';
}
if (overrides.type === 'percent' && overrides.percentAmount && (overrides.percentAmount < 0 || overrides.percentAmount >= 100)) {
errors.amount = 'Amount must be between 0 and 100%.';
}
if (overrides.type === 'fixed' && overrides.fixedAmount && overrides.fixedAmount <= 0) {
errors.amount = 'Enter an amount greater than 0.';
}
}}
onChange={(e) => {
handleAmountInput(e);
}}
onKeyDown={() => clearError('amount')}
/>
<div className='absolute right-1.5 top-6 z-10'>
<Select
clearBg={true}
controlClasses={{menu: 'w-20 right-0'}}
options={amountOptions}
selectedOption={overrides.type === 'percent' ? amountOptions[0] : amountOptions[1]}
onSelect={(e) => {
handleAmountTypeChange(e?.value || '');
}}
/>
</div>
</div>
<Select
options={filteredDurationOptions}
selectedOption={filteredDurationOptions.find(option => option.value === overrides.duration)}
title='Duration'
onSelect={e => handleDurationChange(e?.value || '')}
/>
{
overrides.duration === 'repeating' && <TextField title='Duration in months' type='number' onChange={(e) => {
handleDurationInMonthsInput(e);
}} />
}
</>
}
{
overrides.type === 'trial' && <TextField
error={Boolean(errors.amount)}
hint={errors.amount}
title='Trial duration'
type='number'
value={overrides.trialAmount?.toString()}
onBlur={(e) => {
if (Number(e.target.value) < 1) {
errors.amount = 'Free trial must be at least 1 day.';
}
}}
onChange={(e) => {
handleTrialAmountInput(e);
}}
onKeyDown={() => clearError('amount')}/>
}
<TextField
error={Boolean(errors.code)}
hint={errors.code || (overrides.code.value !== '' ? <div className='flex items-center justify-between'><div>{homepageUrl}<span className='font-bold'>{overrides.code.value}</span></div><span></span><Button className='text-xs' color='green' label={`${isCopied ? 'Copied' : 'Copy'}`} size='sm' link onClick={handleCopyClick} /></div> : null)}
placeholder='black-friday'
title='Offer code'
value={overrides.code.value}
onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.code = 'Code is required';
}
}}
onChange={(e) => {
handleCodeInput(e);
}}
onKeyDown={() => clearError('code')}
/>
</div>
</section>
</Form>
</div>
);
};
const parseData = (input: string): { id: string; period: string; currency: string } => {
const [id, period, currency] = input.split('-');
if (!id || !period || !currency) {
throw new Error('Invalid input format. Expected format is: id-period-currency');
}
return {id, period, currency};
};
const AddOfferModal = () => {
const {siteData} = useGlobalData();
const typeOptions = [
{title: 'Discount', description: 'Offer a special reduced price', value: 'percent'},
{title: 'Free trial', description: 'Give free access for a limited time', value: 'trial'}
];
const durationOptions = [
{value: 'once', label: 'First-payment'},
{value: 'repeating', label: 'Multiple-months'},
{value: 'forever', label: 'Forever'}
];
const [href, setHref] = useState<string>('');
const modal = useModal();
const {updateRoute} = useRouting();
const hasOffers = useFeatureFlag('adminXOffers');
const {data: {tiers} = {}} = useBrowseTiers();
const activeTiers = getPaidActiveTiers(tiers || []);
const tierCadenceOptions = getTiersCadences(activeTiers);
const {mutateAsync: addOffer} = useAddOffer();
const [selectedTier, setSelectedTier] = useState({
tier: tierCadenceOptions[0] || {},
dataset: {
id: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).id : '',
period: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).period : '',
currency: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).currency : ''
}
});
const {formState, updateForm, handleSave, saveState, okProps, validate, errors, clearError} = useForm({
initialState: {
disableBackground: false,
name: '',
code: {
isDirty: false,
value: ''
},
displayTitle: {
isDirty: false,
value: ''
},
displayDescription: '',
type: 'percent',
cadence: selectedTier?.dataset?.period || '',
amount: 0,
duration: 'once',
durationInMonths: 0,
currency: selectedTier?.dataset?.currency || 'USD',
status: 'active',
tierId: selectedTier?.dataset?.id || '',
trialAmount: 7,
fixedAmount: 0,
percentAmount: 0
},
onSave: async () => {
const dataset = {
name: formState.name,
code: formState.code.value,
display_title: formState.displayTitle.value,
display_description: formState.displayDescription,
cadence: formState.cadence,
amount: calculateAmount(formState) || 0,
duration: formState.type === 'trial' ? 'trial' : formState.duration,
duration_in_months: Number(formState.durationInMonths),
currency: formState.currency,
status: formState.status,
tier: {
id: formState.tierId
},
type: formState.type,
currency_restriction: false
};
const response = await addOffer(dataset);
if (response && response.offers && response.offers.length > 0) {
updateRoute(`offers/success/${response.offers[0].id}`);
}
},
onSaveError: () => {},
onValidate: () => {
const newErrors : Record<string, string> = {};
if (!formState.name && formState.name.length === 0) {
newErrors.name = 'Name is required';
}
if (!formState.code.value && formState.code.value.length === 0) {
newErrors.code = 'Code is required';
}
if (!formState.displayTitle.value && formState.displayTitle.value.length === 0) {
newErrors.displayTitle = 'Display title is required';
}
if (formState.type === 'percent' && formState.percentAmount === 0) {
newErrors.amount = 'Enter an amount greater than 0.';
}
if (formState.type === 'percent' && (formState.percentAmount < 0 || formState.percentAmount >= 100)) {
newErrors.amount = 'Amount must be between 0 and 100%.';
}
if (formState.type === 'fixed' && formState.fixedAmount === 0 || formState.type === 'fixed' && formState.fixedAmount < 1) {
newErrors.amount = 'Enter an amount greater than 0.';
}
if (formState.type === 'trial' && formState.trialAmount === 0) {
newErrors.amount = 'Enter an amount greater than 0.';
}
if (formState.type === 'trial' && formState.trialAmount < 1) {
newErrors.amount = 'Free trial must be at least 1 day.';
}
return newErrors;
},
savingDelay: 500
});
const amountOptions = [
{value: 'percent', label: '%'},
{value: 'fixed', label: formState.currency}
];
const handleTierChange = (tier: SelectOption) => {
setSelectedTier({
tier,
dataset: parseData(tier.value)
});
updateForm(state => ({
...state,
cadence: parseData(tier.value).period,
currency: parseData(tier.value).currency,
tierId: parseData(tier.value).id
}));
};
const handleTypeChange = (type: string) => {
updateForm(state => ({
...state,
type: type
}));
};
const handleAmountTypeChange = (amountType: string) => {
updateForm(state => ({
...state,
type: amountType === 'percent' ? 'percent' : 'fixed' || state.type
}));
};
const handleAmountInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
if (formState.type === 'fixed') {
updateForm(state => ({
...state,
fixedAmount: Number(target.value)
}));
} else if (formState.type === 'percent') {
updateForm(state => ({
...state,
percentAmount: Number(target.value)
}));
} else {
updateForm(state => ({
...state,
amount: Number(target.value)
}));
}
};
const handleDurationInMonthsInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
updateForm(state => ({
...state,
durationInMonths: Number(target.value)
}));
};
const handleNameInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
updateForm((prevOverrides) => {
let newOverrides = {...prevOverrides};
newOverrides.name = newValue;
if (!prevOverrides.code.isDirty) {
newOverrides.code = {
...prevOverrides.code,
value: slugify(newValue)
};
}
if (!prevOverrides.displayTitle.isDirty) {
newOverrides.displayTitle = {
...prevOverrides.displayTitle,
value: newValue
};
}
return newOverrides;
});
};
const handleDisplayTitleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
updateForm(state => ({
...state,
displayTitle: {
...state.displayTitle,
isDirty: true,
value: target.value
}
}));
};
const handleTextAreaInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement;
updateForm(state => ({
...state,
displayDescription: target.value
}));
};
const handleTrialAmountInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
updateForm(state => ({
...state,
trialAmount: Number(target.value)
}));
};
const handleDurationChange = (duration: string) => {
updateForm(state => ({
...state,
duration: duration
}));
};
const handleCodeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
updateForm(state => ({
...state,
code: {
...state.code,
isDirty: true,
value: target.value
}
}));
};
useEffect(() => {
if (!hasOffers) {
modal.remove();
updateRoute('');
}
}, [hasOffers, modal, updateRoute]);
const cancelAddOffer = () => {
updateRoute('offers/edit');
};
const overrides : offerPortalPreviewUrlTypes = useMemo(() => {
return {
name: formState.name || '',
code: formState.code.value || '',
displayTitle: formState.displayTitle.value || '',
displayDescription: formState.displayDescription || '',
type: formState.type || 'percent',
cadence: formState.cadence || 'month',
amount: calculateAmount(formState) || 0,
duration: formState.type === 'trial' ? 'trial' : formState.duration || 'once',
durationInMonths: formState.durationInMonths || 0,
currency: formState.currency || 'USD',
status: formState.status || 'active',
tierId: formState.tierId || activeTiers[0]?.id
};
}, [formState, activeTiers]);
useEffect(() => {
const newHref = getOfferPortalPreviewUrl(overrides, siteData.url);
setHref(newHref);
}, [formState, siteData.url, formState.type, overrides]);
const sidebar = <Sidebar
amountOptions={amountOptions as SelectOption[]}
clearError={clearError}
durationOptions={durationOptions}
errors={errors}
handleAmountInput={handleAmountInput}
handleAmountTypeChange={handleAmountTypeChange}
handleCodeInput={handleCodeInput}
handleDisplayTitleInput={handleDisplayTitleInput}
handleDurationChange={handleDurationChange}
handleDurationInMonthsInput={handleDurationInMonthsInput}
handleNameInput={handleNameInput}
handleTextAreaInput={handleTextAreaInput}
handleTierChange={handleTierChange}
handleTrialAmountInput={handleTrialAmountInput}
handleTypeChange={handleTypeChange}
overrides={formState}
selectedTier={selectedTier.tier}
testId='add-offer-sidebar'
tierOptions={tierCadenceOptions}
typeOptions={typeOptions}
validate={validate}
/>;
const iframe = <PortalFrame
href={href}
/>;
return <PreviewModalContent
afterClose={() => {
updateRoute('offers');
}}
backDropClick={false}
cancelLabel='Cancel'
deviceSelector={false}
dirty={saveState === 'unsaved'}
height='full'
okColor={okProps.color}
okLabel='Publish'
preview={iframe}
sidebar={sidebar}
size='lg'
testId='add-offer-modal'
title='Offer'
width={1140}
onCancel={cancelAddOffer}
onOk={async () => {
validate();
const isErrorsEmpty = Object.values(errors).every(error => !error);
if (!isErrorsEmpty) {
showToast({
type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly'
});
return;
}
if (!(await handleSave())) {
showToast({
type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields.'
});
}
}}
/>;
};
export default AddOfferModal;