mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 03:44:29 +03:00
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:
parent
f1cd6432a8
commit
86ad035fbb
@ -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;
|
@ -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}
|
||||
>
|
||||
|
@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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 —</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 —</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;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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}.`;
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
Loading…
Reference in New Issue
Block a user