Wired up AdminX Tips & Donations (#17846)

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

---

This pull request adds and improves features for the membership settings
app, especially for the tips or donations feature. It introduces a new
`CurrencyField` component and a `currency` module for handling currency
input and display. It also refactors and enhances some existing
components, hooks, and types for better user experience and code
quality. It affects files such as `TierDetailModal.tsx`,
`TipsOrDonations.tsx`, `useForm.ts`, and `currency.ts`.
This commit is contained in:
Jono M 2023-08-30 10:08:31 +01:00 committed by GitHub
parent f1cd6432a8
commit 86ad035fbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 236 additions and 82 deletions

View File

@ -0,0 +1,39 @@
import React, {useState} from 'react';
import TextField, {TextFieldProps} from './TextField';
import {currencyFromDecimal, currencyToDecimal} from '../../../utils/currency';
export type CurrencyFieldProps = Omit<TextFieldProps, 'type' | 'onChange'> & {
currency?: string
onChange?: (value: number) => void
}
const CurrencyField: React.FC<CurrencyFieldProps> = ({
value,
onChange,
...props
}) => {
const [localValue, setLocalValue] = useState(currencyToDecimal(parseInt(value || '0')).toString());
// While the user is editing we allow more lenient input, e.g. "1.32.566" to make it easier to type and change
const stripNonNumeric = (input: string) => input.replace(/[^\d.]+/g, '');
// The saved value is strictly a number with 2 decimal places
const forceCurrencyValue = (input: string) => {
return currencyFromDecimal(parseFloat(input.match(/[\d]+\.?[\d]{0,2}/)?.[0] || '0'));
};
return <TextField
{...props}
value={localValue}
onBlur={(e) => {
setLocalValue(currencyToDecimal(forceCurrencyValue(e.target.value)).toString());
props.onBlur?.(e);
}}
onChange={(e) => {
setLocalValue(stripNonNumeric(e.target.value));
onChange?.(forceCurrencyValue(e.target.value));
}}
/>;
};
export default CurrencyField;

View File

@ -7,11 +7,13 @@ import clsx from 'clsx';
export interface SelectOption {
value: string;
label: string;
key?: string;
className?: string;
}
export interface SelectOptionGroup {
label: string;
key?: string;
options: SelectOption[];
}
@ -100,10 +102,10 @@ const Select: React.FC<SelectProps> = ({
{prompt && <option className={optionClasses} value="" disabled selected>{prompt}</option>}
{options.map(option => (
'options' in option ?
<optgroup key={option.label} label={option.label}>
<optgroup key={option.key || option.label} label={option.label}>
{option.options.map(child => (
<option
key={child.value}
key={child.key || child.value}
className={clsx(optionClasses, child.className)}
value={child.value}
>
@ -112,7 +114,7 @@ const Select: React.FC<SelectProps> = ({
))}
</optgroup> :
<option
key={option.value}
key={option.key || option.value}
className={clsx(optionClasses, option.className)}
value={option.value}
>

View File

@ -25,7 +25,7 @@ export const useBrowseSettings = createQuery<SettingsResponseType>({
dataType,
path: '/settings/',
defaultSearchParams: {
group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement,pintura'
group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement,pintura,donations'
}
});

View File

@ -16,7 +16,7 @@ import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../../admin-x-ds/global/form/ToggleGroup';
import useForm from '../../../../hooks/useForm';
import useForm, {ErrorMessages} from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import validator from 'validator';
import {Newsletter, useEditNewsletter} from '../../../../api/newsletters';
@ -36,7 +36,7 @@ const Sidebar: React.FC<{
newsletter: Newsletter;
updateNewsletter: (fields: Partial<Newsletter>) => void;
validate: () => void;
errors: Record<string, string>;
errors: ErrorMessages;
clearError: (field: string) => void;
}> = ({newsletter, updateNewsletter, validate, errors, clearError}) => {
const {settings, siteData, config} = useGlobalData();

View File

@ -1,21 +1,61 @@
import Button from '../../../admin-x-ds/global/Button';
import CurrencyField from '../../../admin-x-ds/global/form/CurrencyField';
import Heading from '../../../admin-x-ds/global/Heading';
import React from 'react';
import React, {useEffect, useState} from 'react';
import Select from '../../../admin-x-ds/global/form/Select';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {confirmIfDirty} from '../../../utils/modals';
import {currencySelectGroups, getSymbol, validateCurrencyAmount} from '../../../utils/currency';
import {getSettingValues} from '../../../api/settings';
const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
localSettings,
siteData,
updateSetting,
isEditing,
saveState,
handleSave,
handleCancel,
focusRef,
handleEditingChange
} = useSettingGroup();
handleEditingChange,
errors,
validate,
clearError
} = useSettingGroup({
onValidate: () => {
return {
donationsSuggestedAmount: validateCurrencyAmount(suggestedAmountInCents, donationsCurrency)
};
}
});
const [donationsCurrency = 'USD', donationsSuggestedAmount = '0'] = getSettingValues<string>(
localSettings,
['donations_currency', 'donations_suggested_amount']
);
const suggestedAmountInCents = parseInt(donationsSuggestedAmount);
const suggestedAmountInDollars = suggestedAmountInCents / 100;
const donateUrl = `${siteData?.url.replace(/\/$/, '')}/#/portal/support`;
useEffect(() => {
validate();
}, [donationsCurrency]); // eslint-disable-line react-hooks/exhaustive-deps
const [copied, setCopied] = useState(false);
const copyDonateUrl = () => {
navigator.clipboard.writeText(donateUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const openPreview = () => {
confirmIfDirty(saveState === 'unsaved', () => window.open(donateUrl, '_blank'));
};
const values = (
<SettingGroupContent
@ -24,7 +64,7 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
{
heading: 'Suggested amount',
key: 'suggested-amount',
value: '$12'
value: `${getSymbol(donationsCurrency)}${suggestedAmountInDollars}`
},
{
heading: '',
@ -32,13 +72,13 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
value: (
<div className='w-100'>
<div className='flex items-center gap-2'>
<Heading level={6}>Sharable link &mdash;</Heading>
<a className='text-2xs font-semibold uppercase tracking-wider text-green' href="https://ghost.org" rel="noopener noreferrer" target="_blank">Preview</a>
<Heading level={6}>Shareable link &mdash;</Heading>
<button className='text-2xs font-semibold uppercase tracking-wider text-green' type="button" onClick={openPreview}>Preview</button>
</div>
<div className='w-100 group relative -m-1 mt-0 overflow-hidden rounded p-1 hover:bg-grey-50'>
https://example.com/tip
{donateUrl}
<div className='invisible absolute right-0 top-[50%] flex translate-y-[-50%] gap-1 bg-white pl-1 group-hover:visible'>
<Button color='outline' label='Copy' size='sm' />
<Button color='outline' label={copied ? 'Copied' : 'Copy'} size='sm' onClick={copyDonateUrl} />
</div>
</div>
</div>
@ -50,23 +90,25 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const inputFields = (
<SettingGroupContent className='max-w-[180px]'>
<TextField
<CurrencyField
error={!!errors.donationsSuggestedAmount}
hint={errors.donationsSuggestedAmount}
inputRef={focusRef}
placeholder="0"
rightPlaceholder={(
<Select
border={false}
options={[
{label: 'USD', value: 'usd'},
{label: 'EUR', value: 'eur'}
]}
options={currencySelectGroups()}
selectClassName='w-auto'
onSelect={() => {}}
selectedOption={donationsCurrency}
onSelect={currency => updateSetting('donations_currency', currency)}
/>
)}
title='Suggested amount'
value='12'
onChange={() => {}}
value={donationsSuggestedAmount}
onBlur={validate}
onChange={cents => updateSetting('donations_suggested_amount', cents.toString())}
onKeyDown={() => clearError('donationsSuggestedAmount')}
/>
</SettingGroupContent>
);
@ -89,4 +131,4 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default TipsOrDonations;
export default TipsOrDonations;

View File

@ -1,10 +1,11 @@
import Button from '../../../../admin-x-ds/global/Button';
import CurrencyField from '../../../../admin-x-ds/global/form/CurrencyField';
import Form from '../../../../admin-x-ds/global/form/Form';
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, {useState} from 'react';
import React, {useEffect, 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';
@ -15,7 +16,7 @@ import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import useSortableIndexedList from '../../../../hooks/useSortableIndexedList';
import {Tier, useAddTier, useEditTier} from '../../../../api/tiers';
import {currencies, currencyFromDecimal, currencyGroups, currencyToDecimal, getSymbol} from '../../../../utils/currency';
import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency';
import {getSettingValues} from '../../../../api/settings';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
@ -24,9 +25,7 @@ interface TierDetailModalProps {
tier?: Tier
}
type TierFormState = Partial<Omit<Tier, 'monthly_price' | 'yearly_price' | 'trial_days'>> & {
monthly_price: string;
yearly_price: string;
export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
trial_days: string;
};
@ -51,21 +50,17 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
const {formState, saveState, 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() || '',
currency: tier?.currency || currencies[0].isoCode
},
onSave: async () => {
const {monthly_price: monthlyPrice, yearly_price: yearlyPrice, trial_days: trialDays, currency, ...rest} = formState;
const {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(trialDays);
}
@ -79,12 +74,10 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
}
});
const currencySymbol = formState.currency ? getSymbol(formState.currency) : '$';
const validators = {
name: () => setError('name', formState.name ? undefined : 'You must specify a name'),
monthly_price: () => setError('monthly_price', (isFreeTier || (formState.monthly_price && parseFloat(formState.monthly_price) >= 1)) ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`),
yearly_price: () => setError('yearly_price', (isFreeTier || (formState.yearly_price && parseFloat(formState.yearly_price) >= 1)) ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`)
monthly_price: () => formState.type !== 'free' && setError('monthly_price', validateCurrencyAmount(formState.monthly_price || 0, formState.currency, {allowZero: false})),
yearly_price: () => formState.type !== 'free' && setError('yearly_price', validateCurrencyAmount(formState.yearly_price || 0, formState.currency, {allowZero: false}))
};
const benefits = useSortableIndexedList({
@ -94,10 +87,6 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
canAddNewItem: item => !!item
});
const forceCurrencyValue = (value: string) => {
return value.match(/[\d]+\.?[\d]{0,2}/)?.[0] || '';
};
const toggleFreeTrial = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setHasFreeTrial(true);
@ -108,6 +97,11 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
}
};
useEffect(() => {
validators.monthly_price();
validators.yearly_price();
}, [formState.currency]); // eslint-disable-line react-hooks/exhaustive-deps
return <Modal
afterClose={() => {
updateRoute('tiers');
@ -118,7 +112,9 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
testId='tier-detail-modal'
title='Tier'
stickyFooter
onOk={() => {
onOk={async () => {
toast.remove();
if (Object.values(validators).filter(validator => validator()).length) {
showToast({
type: 'pageError',
@ -127,11 +123,7 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
return;
}
handleSave();
if (saveState !== 'unsaved') {
toast.dismiss();
modal.remove();
if (await handleSave()) {
updateRoute('tiers');
}
}}
@ -163,13 +155,7 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
<div className='w-10'>
<Select
border={false}
options={Object.values(currencyGroups()).map(group => ({
label: '—',
options: group.map(({isoCode,name}) => ({
value: isoCode,
label: `${isoCode} - ${name}`
}))
}))}
options={currencySelectGroups()}
selectClassName='font-medium'
selectedOption={formState.currency}
size='xs'
@ -178,27 +164,27 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
</div>
</div>
<div className='flex flex-col gap-2'>
<TextField
<CurrencyField
error={Boolean(errors.monthly_price)}
hint={errors.monthly_price}
placeholder='1'
rightPlaceholder={`${formState.currency}/month`}
title='Monthly price'
value={formState.monthly_price}
value={formState.monthly_price?.toString() || ''}
hideTitle
onBlur={() => validators.monthly_price()}
onChange={e => updateForm(state => ({...state, monthly_price: forceCurrencyValue(e.target.value)}))}
onChange={price => updateForm(state => ({...state, monthly_price: price}))}
/>
<TextField
<CurrencyField
error={Boolean(errors.yearly_price)}
hint={errors.yearly_price}
placeholder='10'
rightPlaceholder={`${formState.currency}/year`}
title='Yearly price'
value={formState.yearly_price}
value={formState.yearly_price?.toString() || ''}
hideTitle
onBlur={() => validators.yearly_price()}
onChange={e => updateForm(state => ({...state, yearly_price: forceCurrencyValue(e.target.value)}))}
onChange={price => updateForm(state => ({...state, yearly_price: price}))}
/>
</div>
</div>

View File

@ -3,17 +3,11 @@ import Heading from '../../../../admin-x-ds/global/Heading';
import Icon from '../../../../admin-x-ds/global/Icon';
import React, {useState} from 'react';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {Tier} from '../../../../api/tiers';
import {TierFormState} from './TierDetailModal';
import {currencyToDecimal, getSymbol} from '../../../../utils/currency';
import {getSettingValues} from '../../../../api/settings';
import {getSymbol} from '../../../../utils/currency';
import {numberWithCommas} from '../../../../utils/helpers';
export type TierFormState = Partial<Omit<Tier, 'monthly_price' | 'yearly_price' | 'trial_days'>> & {
monthly_price: string;
yearly_price: string;
trial_days: string;
};
interface TierDetailPreviewProps {
tier: TierFormState;
isFreeTier: boolean;
@ -81,8 +75,8 @@ const TierDetailPreview: React.FC<TierDetailPreviewProps> = ({tier, isFreeTier})
const currencySymbol = currency ? getSymbol(currency) : '$';
const benefits = tier?.benefits || [];
const monthlyPrice = parseFloat(tier?.monthly_price || '0');
const yearlyPrice = parseFloat(tier?.yearly_price || '0');
const monthlyPrice = currencyToDecimal(tier?.monthly_price || 0);
const yearlyPrice = currencyToDecimal(tier?.yearly_price || 0);
const yearlyDiscount = tier?.monthly_price && tier?.yearly_price
? Math.ceil(((monthlyPrice * 12 - yearlyPrice) / (monthlyPrice * 12)) * 100)
: 0;

View File

@ -6,6 +6,8 @@ export type Dirtyable<Data> = Data & {
export type SaveState = 'unsaved' | 'saving' | 'saved' | 'error' | '';
export type ErrorMessages = Record<string, string | undefined>
export interface FormHook<State> {
formState: State;
saveState: SaveState;
@ -23,17 +25,17 @@ export interface FormHook<State> {
validate: () => boolean;
clearError: (field: string) => void;
isValid: boolean;
errors: Record<string, string>;
errors: ErrorMessages;
}
const useForm = <State>({initialState, onSave, onValidate}: {
initialState: State,
onSave: () => void | Promise<void>
onValidate?: () => Record<string, string>
onValidate?: () => ErrorMessages
}): FormHook<State> => {
const [formState, setFormState] = useState(initialState);
const [saveState, setSaveState] = useState<SaveState>('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<ErrorMessages>({});
// Reset saved state after 2 seconds
useEffect(() => {
@ -44,7 +46,7 @@ const useForm = <State>({initialState, onSave, onValidate}: {
}
}, [saveState]);
const isValid = (errs: Record<string, string>) => Object.values(errs).filter(Boolean).length === 0;
const isValid = (errs: ErrorMessages) => Object.values(errs).filter(Boolean).length === 0;
const validate = () => {
if (!onValidate) {

View File

@ -1,8 +1,10 @@
import React, {useEffect, useRef, useState} from 'react';
import useForm, {SaveState} from './useForm';
import useForm, {ErrorMessages, SaveState} from './useForm';
import useGlobalDirtyState from './useGlobalDirtyState';
import {Setting, SettingValue, useEditSettings} from '../api/settings';
import {SiteData} from '../api/site';
import {showToast} from '../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
import {useGlobalData} from '../components/providers/GlobalDataProvider';
interface LocalSetting extends Setting {
@ -20,11 +22,11 @@ export interface SettingGroupHook {
updateSetting: (key: string, value: SettingValue) => void;
handleEditingChange: (newState: boolean) => void;
validate: () => boolean;
errors: Record<string, string>;
errors: ErrorMessages;
clearError: (key: string) => void;
}
const useSettingGroup = ({onValidate}: {onValidate?: () => Record<string, string>} = {}): SettingGroupHook => {
const useSettingGroup = ({onValidate}: {onValidate?: () => ErrorMessages} = {}): SettingGroupHook => {
// create a ref to focus the input field
const focusRef = useRef<HTMLInputElement>(null);
@ -95,9 +97,17 @@ const useSettingGroup = ({onValidate}: {onValidate?: () => Record<string, string
saveState,
focusRef,
siteData,
handleSave: () => {
const result = handleSave();
setEditing(false);
handleSave: async () => {
toast.remove();
const result = await handleSave();
if (result) {
setEditing(false);
} else {
showToast({
type: 'pageError',
message: 'Can\'t save settings! One or more fields have errors, please doublecheck you filled all mandatory fields'
});
}
return result;
},
handleCancel,

View File

@ -1,3 +1,5 @@
import {SelectOptionGroup} from '../admin-x-ds/global/form/Select';
type CurrencyOption = {
isoCode: string;
name: string;
@ -132,6 +134,17 @@ export function currencyGroups() {
};
}
export function currencySelectGroups({showName = false} = {}): SelectOptionGroup[] {
return Object.values(currencyGroups()).map((group, index) => ({
label: '—',
key: index.toString(),
options: group.map(({isoCode,name}) => ({
value: isoCode,
label: showName ? `${isoCode} - ${name}` : isoCode
}))
}));
}
export function getSymbol(currency: string): string {
if (!currency) {
return '';
@ -147,3 +160,69 @@ export function currencyToDecimal(integerAmount: number): number {
export function currencyFromDecimal(decimalAmount: number): number {
return decimalAmount * 100;
}
/*
* Returns the minimum charge amount for a given currency,
* based on Stripe's requirements. Values here are double the Stripe limits, to take conversions to the settlement currency into account.
* @see https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
*/
export function minimumAmountForCurrency(currency: string) {
const isoCurrency = currency?.toUpperCase();
switch (isoCurrency) {
case 'AED':
return 4;
case 'BGN':
return 2;
case 'CZK':
return 30;
case 'DKK':
return 5;
case 'HKD':
return 8;
case 'HUF':
return 250;
case 'JPY':
return 100;
case 'MXN':
return 20;
case 'MYR':
return 4;
case 'NOK':
return 6;
case 'PLN':
return 4;
case 'RON':
return 4;
case 'SEK':
return 6;
case 'THB':
return 20;
default:
return 1;
}
}
// Stripe doesn't allow amounts over 10,000 as a preset amount
const MAX_AMOUNT = 10_000;
export function validateCurrencyAmount(cents: number | undefined, currency: string | undefined, {allowZero = true} = {}) {
if (cents === undefined || !currency) {
return;
}
const symbol = getSymbol(currency);
const minAmount = minimumAmountForCurrency(currency);
if (!allowZero && cents === 0) {
return `Amount must be at least ${symbol}${minAmount}.`;
}
if (cents !== 0 && cents < (minAmount * 100)) {
return `Non-zero amount must be at least ${symbol}${minAmount}.`;
}
if (cents !== 0 && cents > (MAX_AMOUNT * 100)) {
return `Suggested amount cannot be more than ${symbol}${MAX_AMOUNT}.`;
}
}

View File

@ -28,7 +28,7 @@ test.describe('Tier settings', async () => {
await expect(page.getByTestId('toast')).toHaveText(/One or more fields have errors/);
await expect(modal).toHaveText(/You must specify a name/);
await expect(modal).toHaveText(/Subscription amount must be at least \$1\.00/);
await expect(modal).toHaveText(/Amount must be at least \$1/);
await modal.getByLabel('Name').fill('Plus tier');
await modal.getByLabel('Monthly price').fill('8');