TryGhost/Ghost

View on GitHub
apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx

Summary

Maintainability
F
4 days
Test Coverage
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react';
import TierDetailPreview from './TierDetailPreview';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {Button, ButtonProps, ConfirmationModal, CurrencyField, Form, Heading, Icon, Modal, Select, SortableList, TextField, Toggle, URLTextField, showToast, useSortableIndexedList} from '@tryghost/admin-x-design-system';
import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
import {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency';
import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';

export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
    trial_days: string;
};

const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
    const isFreeTier = tier?.type === 'free';

    const modal = useModal();
    const {updateRoute} = useRouting();
    const {mutateAsync: updateTier} = useEditTier();
    const {mutateAsync: createTier} = useAddTier();
    const {mutateAsync: editSettings} = useEditSettings();
    const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
    const handleError = useHandleError();
    const {localSettings, siteData} = useSettingGroup();
    const [portalPlansJson] = getSettingValues(localSettings, ['portal_plans']) as string[];
    const hasPortalImprovements = useFeatureFlag('portalImprovements');
    const allowNameChange = !isFreeTier || hasPortalImprovements;
    const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];

    const validators: {[key in keyof Tier]?: () => string | undefined} = {
        name: () => (formState.name ? undefined : 'Enter a name for the tier'),
        monthly_price: () => (formState.type !== 'free' ? validateCurrencyAmount(formState.monthly_price || 0, formState.currency, {allowZero: false}) : undefined),
        yearly_price: () => (formState.type !== 'free' ? validateCurrencyAmount(formState.yearly_price || 0, formState.currency, {allowZero: false}) : undefined)
    };

    const {formState, saveState, updateForm, handleSave, errors, clearError, okProps} = useForm<TierFormState>({
        initialState: {
            ...(tier || {}),
            trial_days: tier?.trial_days?.toString() || '',
            currency: tier?.currency || currencies[0].isoCode,
            visibility: tier?.visibility || 'none',
            welcome_page_url: tier?.welcome_page_url || null
        },
        savingDelay: 500,
        savedDelay: 500,
        onValidate: () => {
            const newErrors: ErrorMessages = {};

            Object.entries(validators).forEach(([key, validator]) => {
                newErrors[key as keyof Tier] = validator?.();
            });

            return newErrors;
        },
        onSave: async () => {
            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.trial_days = parseInt(trialDays);
            }

            if (tier?.id) {
                await updateTier({...tier, ...values});
            } else {
                await createTier(values);
            }
            if (isFreeTier && hasPortalImprovements) {
                // If we changed the visibility, we also need to update Portal settings in some situations
                // Like the free tier is a special case, and should also be present/absent in portal_plans
                const visible = formState.visibility === 'public';
                let save = false;

                if (portalPlans.includes('free') && !visible) {
                    portalPlans.splice(portalPlans.indexOf('free'), 1);
                    save = true;
                }

                if (!portalPlans.includes('free') && visible) {
                    portalPlans.push('free');
                    save = true;
                }

                if (save) {
                    await editSettings([
                        {
                            key: 'portal_plans',
                            value: JSON.stringify(portalPlans)
                        }
                    ]);
                }
            }
        },
        onSavedStateReset: () => {
            modal.remove();
            updateRoute('tiers');
        },
        onSaveError: handleError
    });

    const benefits = useSortableIndexedList({
        items: formState.benefits || [],
        setItems: newBenefits => updateForm(state => ({...state, benefits: newBenefits})),
        blank: '',
        canAddNewItem: item => !!item
    });

    const toggleFreeTrial = (e: React.ChangeEvent<HTMLInputElement>) => {
        if (e.target.checked) {
            setHasFreeTrial(true);
            updateForm(state => ({...state, trial_days: tier?.trial_days ? tier?.trial_days.toString() : '7'}));
        } else {
            setHasFreeTrial(false);
            updateForm(state => ({...state, trial_days: '0'}));
        }
    };

    // Only validate amounts when the user changes currency, don't show errors on initial render
    const didInitialRender = useRef(false);
    useEffect(() => {
        if (didInitialRender.current) {
            validators.monthly_price?.();
            validators.yearly_price?.();
        }

        didInitialRender.current = true;
    }, [formState.currency]); // eslint-disable-line react-hooks/exhaustive-deps

    const confirmTierStatusChange = () => {
        if (tier) {
            const promptTitle = tier.active ? 'Archive tier' : 'Reactivate tier';
            const prompt = tier.active ? <>
                <div className='mb-6'>Members will no longer be able to subscribe to <strong>{tier.name}</strong> and it will be removed from the list of available tiers in portal.</div>
                <div>Existing members on this tier will remain unchanged. Offers using this tier will be disabled.</div>
            </> : <>
                <div className='mb-6'>Reactivating <strong>{tier.name}</strong> will re-enable it as an option in portal and allow new members to subscribe to this tier.</div>
                <div>Existing members will remain unchanged.</div>
            </>;
            const okLabel = tier.active ? 'Archive' : 'Reactivate';
            NiceModal.show(ConfirmationModal, {
                title: promptTitle,
                prompt: prompt,
                okLabel: okLabel,
                cancelLabel: 'Cancel',
                okColor: tier.active ? 'red' : 'black',
                onOk: (confirmModal) => {
                    updateTier({...tier, active: !tier.active});
                    confirmModal?.remove();
                    showToast({
                        type: 'success',
                        title: `Tier ${tier.active ? 'archived' : 'reactivated'}`
                    });
                }
            });
        }
    };

    let leftButtonProps: ButtonProps = {};
    if (tier) {
        if (tier.active && tier.type !== 'free') {
            leftButtonProps = {
                label: 'Archive tier',
                color: 'red',
                link: true,
                onClick: confirmTierStatusChange
            };
        } else if (!tier.active) {
            leftButtonProps = {
                label: 'Reactivate tier',
                color: 'green',
                link: true,
                onClick: confirmTierStatusChange
            };
        }
    }

    return <Modal
        afterClose={() => {
            updateRoute('tiers');
        }}
        buttonsDisabled={okProps.disabled}
        dirty={saveState === 'unsaved'}
        leftButtonProps={leftButtonProps}
        okColor={okProps.color}
        okLabel={okProps.label || 'Save & close'}
        size='lg'
        testId='tier-detail-modal'
        title={(tier ? (tier.active ? 'Edit tier' : 'Edit archived tier') : 'New tier')}
        stickyFooter
        onOk={async () => {
            await handleSave({fakeWhenUnchanged: true});
        }}
    >
        <div className='-mb-8 mt-8 flex items-start gap-8'>
            <div className='flex grow flex-col gap-8'>
                <Form marginBottom={false} title='Basic' grouped>
                    {allowNameChange && <TextField
                        autoComplete='off'
                        error={Boolean(errors.name)}
                        hint={errors.name}
                        maxLength={191}
                        placeholder={isFreeTier ? 'Free' : 'Bronze'}
                        title='Name'
                        value={formState.name || ''}
                        autoFocus
                        onChange={e => updateForm(state => ({...state, name: e.target.value}))}
                        onKeyDown={() => clearError('name')}
                    />}
                    <TextField
                        autoComplete='off'
                        autoFocus={isFreeTier}
                        maxLength={191}
                        placeholder={isFreeTier ? `Free preview` : 'Full access to premium content'}
                        title='Description'
                        value={formState.description || ''}
                        onChange={e => updateForm(state => ({...state, description: e.target.value}))}
                    />
                    {!isFreeTier &&
                    (<>
                        <div className='flex flex-col gap-10 md:flex-row'>
                            <div className='basis-1/2'>
                                <div className='mb-1 flex h-6 items-center justify-between'>
                                    <Heading level={6}>Prices</Heading>
                                    <div className='-mr-2 w-[50px]'>
                                        <Select
                                            border={false}
                                            containerClassName='font-medium'
                                            controlClasses={{menu: 'w-18'}}
                                            options={currencySelectGroups()}
                                            selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === formState.currency)}
                                            size='xs'
                                            clearBg
                                            isSearchable
                                            onSelect={option => updateForm(state => ({...state, currency: option?.value}))}
                                        />
                                    </div>
                                </div>
                                <div className='flex flex-col gap-2'>
                                    <CurrencyField
                                        error={Boolean(errors.monthly_price)}
                                        hint={errors.monthly_price}
                                        placeholder='5'
                                        rightPlaceholder={`${formState.currency}/month`}
                                        title='Monthly price'
                                        valueInCents={formState.monthly_price || ''}
                                        hideTitle
                                        onBlur={event => ((event.target.value === '') ? updateForm(state => ({...state, monthly_price: 0})) : null)}
                                        onChange={price => updateForm(state => ({...state, monthly_price: price}))}
                                        onKeyDown={() => clearError('monthly_price')}
                                    />
                                    <CurrencyField
                                        error={Boolean(errors.yearly_price)}
                                        hint={errors.yearly_price}
                                        placeholder='50'
                                        rightPlaceholder={`${formState.currency}/year`}
                                        title='Yearly price'
                                        valueInCents={formState.yearly_price || ''}
                                        hideTitle
                                        onBlur={event => ((event.target.value === '') ? updateForm(state => ({...state, yearly_price: 0})) : null)}
                                        onChange={price => updateForm(state => ({...state, yearly_price: price}))}
                                        onKeyDown={() => clearError('yearly_price')}
                                    />
                                </div>
                            </div>
                            <div className='basis-1/2'>
                                <div className='mb-1 flex h-6 flex-col justify-center'>
                                    <Toggle checked={hasFreeTrial} label='Add a free trial' labelStyle='heading' onChange={toggleFreeTrial} />
                                </div>
                                <TextField
                                    disabled={!hasFreeTrial}
                                    hint={<div className='mt-1'>
                                    Members will be subscribed at full price once the trial ends. <a className='text-green' href="https://ghost.org/" rel="noreferrer" target="_blank">Learn more</a>
                                    </div>}
                                    placeholder='0'
                                    rightPlaceholder='days'
                                    title='Trial days'
                                    value={formState.trial_days}
                                    hideTitle
                                    onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))}
                                />
                            </div>
                        </div>
                    </>)}
                    <URLTextField
                        baseUrl={siteData?.url}
                        hint={`Redirect to this URL after signup ${isFreeTier ? '' : ' for premium membership'}`}
                        maxLength={2000}
                        placeholder={siteData?.url}
                        title='Welcome page'
                        value={formState.welcome_page_url || null}
                        nullable
                        transformPathWithoutSlash
                        onChange={value => updateForm(state => ({...state, welcome_page_url: value || null}))}
                    />
                </Form>

                <Form gap='none' title='Benefits' grouped>
                    <div className='-mt-3'>
                        <SortableList
                            items={benefits.items}
                            itemSeparator={false}
                            renderItem={({id, item}) => <div className='relative flex w-full items-center gap-5'>
                                <div className='absolute left-[-32px] top-[7px] flex h-6 w-6 items-center justify-center bg-white group-hover:hidden dark:bg-black'><Icon name='check' size='sm' /></div>
                                <TextField
                                    // className='grow border-b border-grey-500 py-2 focus:border-grey-800 group-hover:border-grey-600'
                                    maxLength={191}
                                    value={item}
                                    onChange={e => benefits.updateItem(id, e.target.value)}
                                />
                                <Button className='absolute right-1 top-1 z-10 opacity-0 group-hover:opacity-100' color='grey' icon='trash' size='sm' onClick={() => benefits.removeItem(id)} />
                            </div>}
                            onMove={benefits.moveItem}
                        />
                    </div>
                    <div className="relative mt-1 flex items-center gap-3">
                        <Icon className='dark:text-white' name='check' size='sm' />
                        <TextField
                            className='grow'
                            containerClassName='w-100'
                            maxLength={191}
                            placeholder='Expert analysis'
                            title='New benefit'
                            value={benefits.newItem}
                            hideTitle
                            onChange={e => benefits.setNewItem(e.target.value)}
                            onKeyDown={(e) => {
                                if (e.key === 'Enter') {
                                    benefits.addItem();
                                }
                            }}
                        />
                        <Button
                            className='absolute right-[5px] top-[5px] z-10'
                            color='green'
                            icon='add'
                            iconColorClass='text-white'
                            label='Add'
                            size='sm'
                            hideLabel
                            onClick={() => benefits.addItem()}
                        />
                    </div>
                </Form>
            </div>
            <div className='sticky top-[96px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'>
                <TierDetailPreview isFreeTier={isFreeTier} tier={formState} />
            </div>
        </div>
    </Modal>;
};

const TierDetailModal: React.FC<RoutingModalProps> = ({params}) => {
    const {data: {tiers, isEnd} = {}, fetchNextPage} = useBrowseTiers();

    let tier: Tier | undefined;

    useEffect(() => {
        if (params?.id && !tier && !isEnd) {
            fetchNextPage();
        }
    }, [fetchNextPage, isEnd, params?.id, tier]);

    if (params?.id) {
        tier = tiers?.find(({id}) => id === params?.id);

        if (!tier) {
            return null;
        }
    }

    return <TierDetailModalContent tier={tier} />;
};

export default NiceModal.create(TierDetailModal);