mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-28 21:33:24 +03:00
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:
parent
5c7e205ad6
commit
2e337c4e8c
@ -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>
|
||||
|
@ -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 = [
|
||||
|
@ -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>;
|
||||
|
@ -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>
|
||||
|
@ -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>}
|
||||
|
@ -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 '';
|
||||
|
Loading…
Reference in New Issue
Block a user