Added free tier handling and other fixes to tiers modal (#17361)

refs https://github.com/TryGhost/Product/issues/3580

- Made free trial toggle work properly
- Updated currency picker to work correctly
- Enforce format on tier prices
- Added support for editing the free tier
- Added validations to tier modal
- Updated tier modal to remove blank benefits before saving
This commit is contained in:
Jono M 2023-07-14 10:46:16 +09:00 committed by GitHub
parent 5c7e205ad6
commit 2e337c4e8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 121 additions and 49 deletions

View File

@ -9,11 +9,16 @@ export interface SelectOption {
label: string;
}
export interface SelectOptionGroup {
label: string;
options: SelectOption[];
}
export interface SelectProps {
title?: string;
size?: 'xs' | 'md';
prompt?: string;
options: SelectOption[];
options: SelectOption[] | SelectOptionGroup[];
selectedOption?: string
onSelect: (value: string) => void;
error?:boolean;
@ -87,13 +92,25 @@ const Select: React.FC<SelectProps> = ({
<select className={selectClasses} id={id} value={selectedOption} onChange={handleOptionChange}>
{prompt && <option className={optionClasses} value="">{prompt}</option>}
{options.map(option => (
<option
key={option.value}
className={optionClasses}
value={option.value}
>
{option.label}
</option>
'options' in option ?
<optgroup key={option.label} label={option.label}>
{option.options.map(child => (
<option
key={child.value}
className={optionClasses}
value={child.value}
>
{child.label}
</option>
))}
</optgroup> :
<option
key={option.value}
className={optionClasses}
value={option.value}
>
{option.label}
</option>
))}
</select>
</div>

View File

@ -2,13 +2,13 @@ import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import TiersList from './tiers/TiersList';
import {getArchivedTiers, getPaidActiveTiers} from '../../../utils/helpers';
import {getActiveTiers, getArchivedTiers} from '../../../utils/helpers';
import {useTiers} from '../../providers/ServiceProvider';
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState('active-tiers');
const {data: tiers, update: updateTier} = useTiers();
const activeTiers = getPaidActiveTiers(tiers);
const activeTiers = getActiveTiers(tiers);
const archivedTiers = getArchivedTiers(tiers);
const tabs = [

View File

@ -4,7 +4,7 @@ import Heading from '../../../../admin-x-ds/global/Heading';
import Icon from '../../../../admin-x-ds/global/Icon';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import React, {useState} from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import SortableList from '../../../../admin-x-ds/global/SortableList';
import TextField from '../../../../admin-x-ds/global/form/TextField';
@ -14,7 +14,8 @@ import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import useSortableIndexedList from '../../../../hooks/useSortableIndexedList';
import {Tier} from '../../../../types/api';
import {currencyFromDecimal, currencyToDecimal} from '../../../../utils/currency';
import {currencies, currencyFromDecimal, currencyGroups, currencyToDecimal, getSymbol} from '../../../../utils/currency';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {useTiers} from '../../../providers/ServiceProvider';
interface TierDetailModalProps {
@ -28,32 +29,58 @@ type TierFormState = Partial<Omit<Tier, 'monthly_price' | 'yearly_price' | 'tria
};
const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
const isFreeTier = tier?.type === 'free';
const modal = useModal();
const {updateRoute} = useRouting();
const {update: updateTier, create: createTier} = useTiers();
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
const [errors, setErrors] = useState<{ [key in keyof Tier]?: string }>({}); // eslint-disable-line no-unused-vars
const setError = (field: keyof Tier, error: string | undefined) => {
setErrors({...errors, [field]: error});
};
const {formState, updateForm, handleSave} = useForm<TierFormState>({
initialState: {
...(tier || {}),
monthly_price: tier?.monthly_price ? currencyToDecimal(tier.monthly_price).toString() : '',
yearly_price: tier?.yearly_price ? currencyToDecimal(tier.yearly_price).toString() : '',
trial_days: tier?.trial_days?.toString() || ''
trial_days: tier?.trial_days?.toString() || '',
currency: tier?.currency || currencies[0].isoCode
},
onSave: async () => {
const values = {
...formState,
monthly_price: currencyFromDecimal(parseFloat(formState.monthly_price)),
yearly_price: currencyFromDecimal(parseFloat(formState.yearly_price)),
trial_days: currencyFromDecimal(parseFloat(formState.trial_days))
};
if (Object.values(errors).some(error => error)) {
showToast({
type: 'pageError',
message: 'One or more fields have errors'
});
return;
}
const {monthly_price: monthlyPrice, yearly_price: yearlyPrice, trial_days: trialDays, currency, ...rest} = formState;
const values: Partial<Tier> = rest;
values.benefits = values.benefits?.filter(benefit => benefit);
if (!isFreeTier) {
values.currency = currency;
values.monthly_price = currencyFromDecimal(parseFloat(monthlyPrice));
values.yearly_price = currencyFromDecimal(parseFloat(yearlyPrice));
values.trial_days = parseInt(formState.trial_days);
}
if (tier?.id) {
await updateTier({...tier, ...values});
} else {
await createTier(values);
}
modal.remove();
}
});
const benefits = useSortableIndexedList({
items: formState.benefits || [],
setItems: newBenefits => updateForm(state => ({...state, benefits: newBenefits})),
@ -61,6 +88,12 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
canAddNewItem: item => !!item
});
const forceCurrencyValue = (value: string) => {
return value.match(/[\d]+\.?[\d]{0,2}/)?.[0] || '';
};
const currencySymbol = formState.currency ? getSymbol(formState.currency) : '$';
return <Modal
afterClose={() => {
updateRoute('tiers');
@ -69,75 +102,84 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
size='lg'
title='Tier'
stickyFooter
onOk={async () => {
await handleSave();
modal.remove();
}}
onOk={handleSave}
>
<div className='mt-8 flex items-start gap-10'>
<div className='flex grow flex-col gap-5'>
<Form title='Basic' grouped>
<TextField
{!isFreeTier && <TextField
error={Boolean(errors.name)}
hint={errors.name}
placeholder='Bronze'
title='Name'
value={formState.name || ''}
onBlur={e => setError('name', e.target.value ? undefined : 'You must specify a name')}
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
/>
/>}
<TextField
placeholder='Full access to premium content'
title='Description'
value={formState.description || ''}
onChange={e => updateForm(state => ({...state, description: e.target.value}))}
/>
<div className='flex gap-10'>
{!isFreeTier && <div className='flex gap-10'>
<div className='basis-1/2'>
<div className='mb-1 flex h-6 items-center justify-between'>
<Heading level={6}>Prices</Heading>
<div className='w-10'>
<Select
border={false}
options={[
{label: 'USD', value: 'US Dollaz'},
{label: 'HUF', value: 'Hungarian Dollaz'}
]}
options={Object.values(currencyGroups()).map(group => ({
label: '—',
options: group.map(({isoCode,name}) => ({
value: isoCode,
label: `${isoCode} - ${name}`
}))
}))}
selectClassName='font-medium'
size='xs'
onSelect={() => {}}
onSelect={currency => updateForm(state => ({...state, currency}))}
/>
</div>
</div>
<div className='flex flex-col gap-2'>
<TextField
error={Boolean(errors.monthly_price)}
hint={errors.monthly_price}
placeholder='1'
rightPlaceholder='USD/month'
rightPlaceholder={`${formState.currency}/month`}
value={formState.monthly_price}
onChange={e => updateForm(state => ({...state, monthly_price: e.target.value.replace(/[^\d.]/, '')}))}
onBlur={e => setError('monthly_price', e.target.value ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`)}
onChange={e => updateForm(state => ({...state, monthly_price: forceCurrencyValue(e.target.value)}))}
/>
<TextField
error={Boolean(errors.yearly_price)}
hint={errors.yearly_price}
placeholder='10'
rightPlaceholder='USD/year'
rightPlaceholder={`${formState.currency}/year`}
value={formState.yearly_price}
onChange={e => updateForm(state => ({...state, yearly_price: e.target.value.replace(/[^\d.]/, '')}))}
onBlur={e => setError('yearly_price', e.target.value ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`)}
onChange={e => updateForm(state => ({...state, yearly_price: forceCurrencyValue(e.target.value)}))}
/>
</div>
</div>
<div className='basis-1/2'>
<div className='mb-1 flex h-6 items-center justify-between'>
<Heading level={6}>Add a free trial</Heading>
<Toggle onChange={() => {}} />
<Toggle onChange={e => setHasFreeTrial(e.target.checked)} />
</div>
<TextField
disabled={!hasFreeTrial}
hint={<>
Members will be subscribed at full price once the trial ends. <a href="https://ghost.org/" rel="noreferrer" target="_blank">Learn more</a>
</>}
placeholder='0'
rightPlaceholder='days'
value={formState.trial_days}
disabled
onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/^[\d.]/, '')}))}
onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))}
/>
</div>
</div>
</div>}
</Form>
<Form gap='none' title='Benefits' grouped>
@ -169,7 +211,7 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
</Form>
</div>
<div className='sticky top-[77px] shrink-0 basis-[380px]'>
<TierDetailPreview tier={formState} />
<TierDetailPreview isFreeTier={isFreeTier} tier={formState} />
</div>
</div>
</Modal>;

View File

@ -12,7 +12,8 @@ export type TierFormState = Partial<Omit<Tier, 'monthly_price' | 'yearly_price'
};
interface TierDetailPreviewProps {
tier: TierFormState
tier: TierFormState;
isFreeTier: boolean;
}
const TrialDaysLabel: React.FC<{trialDays: number}> = ({trialDays}) => {
@ -65,7 +66,7 @@ const DiscountLabel: React.FC<{discount: number}> = ({discount}) => {
);
};
const TierDetailPreview: React.FC<TierDetailPreviewProps> = ({tier}) => {
const TierDetailPreview: React.FC<TierDetailPreviewProps> = ({tier, isFreeTier}) => {
const [showingYearly, setShowingYearly] = useState(false);
const name = tier?.name || '';
@ -84,20 +85,20 @@ const TierDetailPreview: React.FC<TierDetailPreviewProps> = ({tier}) => {
return (
<div className="mt-[-5px]">
<div className="flex items-baseline justify-between">
<Heading className="pb-2" level={6} grey>Tier preview</Heading>
<div className="flex">
<Heading className="pb-2" level={6} grey>{isFreeTier ? 'Free membership preview' : 'Tier preview'}</Heading>
{!isFreeTier && <div className="flex">
<Button className={`${showingYearly === true ? 'text-grey-500' : 'text-grey-900'}`} label="Monthly" link onClick={() => setShowingYearly(false)} />
<Button className={`ml-2 ${showingYearly === true ? 'text-grey-900' : 'text-grey-500'}`} label="Yearly" link onClick={() => setShowingYearly(true)} />
</div>
</div>}
</div>
<div className="flex-column relative flex min-h-[200px] w-full max-w-[420px] items-start justify-stretch rounded border border-grey-200 bg-white p-8">
<div className="min-h-[56px] w-full">
<h4 className={`-mt-1 mb-0 w-full break-words text-lg font-semibold leading-tight text-pink ${!name && 'opacity-30'}`}>{name || 'Bronze'}</h4>
<div className="mt-4 flex w-full flex-row flex-wrap items-end justify-between gap-x-1 gap-y-[10px]">
<div className={`flex flex-wrap text-black ${!yearlyPrice && !monthlyPrice && 'opacity-30'}`}>
<div className={`flex flex-wrap text-black ${!yearlyPrice && !monthlyPrice && !isFreeTier && 'opacity-30'}`}>
<span className="self-start text-[2.7rem] font-bold uppercase leading-[1.115]">{currencySymbol}</span>
<span className="break-all text-[3.4rem] font-bold leading-none tracking-tight">{showingYearly ? yearlyPrice : monthlyPrice}</span>
<span className="ml-1 self-end text-[1.5rem] leading-snug text-grey-800">/{showingYearly ? 'year' : 'month'}</span>
{!isFreeTier && <span className="ml-1 self-end text-[1.5rem] leading-snug text-grey-800">/{showingYearly ? 'year' : 'month'}</span>}
</div>
<TrialDaysLabel trialDays={trialDays} />
</div>

View File

@ -6,6 +6,7 @@ import React from 'react';
import TierDetailModal from './TierDetailModal';
import useRouting from '../../../../hooks/useRouting';
import {Tier} from '../../../../types/api';
import {currencyToDecimal, getSymbol} from '../../../../utils/currency';
interface TiersListProps {
tab?: string;
@ -24,6 +25,9 @@ const TierCard: React.FC<TierCardProps> = ({
tier,
updateTier
}) => {
const currency = tier?.currency || 'USD';
const currencySymbol = currency ? getSymbol(currency) : '$';
return (
<div className={cardContainerClasses}>
<div className='w-full grow cursor-pointer' onClick={() => {
@ -31,8 +35,9 @@ const TierCard: React.FC<TierCardProps> = ({
}}>
<div className='text-[1.65rem] font-bold tracking-tight text-pink'>{tier.name}</div>
<div className='mt-2 flex items-baseline gap-1'>
<span className='text-2xl font-bold tracking-tighter'>{tier.monthly_price}</span>
<span className='text-sm text-grey-700'>/month</span>
<span className="self-start text-xl font-bold uppercase">{currencySymbol}</span>
<span className='text-2xl font-bold tracking-tighter'>{currencyToDecimal(tier.monthly_price || 0)}</span>
{(tier.monthly_price && tier.monthly_price > 0) && <span className='text-sm text-grey-700'>/month</span>}
</div>
<div className='mt-2 text-sm font-medium'>
{tier.description || <span className='opacity-50'>No description</span>}

View File

@ -125,6 +125,13 @@ export const currencies: CurrencyOption[] = [
{isoCode: 'ZMW', name: 'Zambian kwacha'}
];
export function currencyGroups() {
return {
top: currencies.slice(0, 5),
other: currencies.slice(5)
};
}
export function getSymbol(currency: string): string {
if (!currency) {
return '';