TryGhost/Ghost

View on GitHub
apps/admin-x-settings/src/components/settings/membership/stripe/StripeConnectModal.tsx

Summary

Maintainability
D
1 day
Test Coverage
import BookmarkThumb from '../../../../assets/images/stripe-thumb.jpg';
import GhostLogo from '../../../../assets/images/orb-squircle.png';
import GhostLogoPink from '../../../../assets/images/orb-pink.png';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import StripeLogo from '../../../../assets/images/stripe-emblem.svg';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {Button, ConfirmationModal, Form, Heading, Modal, StripeButton, TextArea, TextField, Toggle, showToast} from '@tryghost/admin-x-design-system';
import {JSONError} from '@tryghost/admin-x-framework/errors';
import {ReactComponent as StripeVerified} from '../../../../assets/images/stripe-verified.svg';
import {checkStripeEnabled, getSettingValue, getSettingValues, useDeleteStripeSettings, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
import {getGhostPaths} from '@tryghost/admin-x-framework/helpers';
import {toast} from 'react-hot-toast';
import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members';
import {useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useRouting} from '@tryghost/admin-x-framework/routing';

const RETRY_PRODUCT_SAVE_POLL_LENGTH = 1000;
const RETRY_PRODUCT_SAVE_MAX_POLL = 15 * RETRY_PRODUCT_SAVE_POLL_LENGTH;

const Start: React.FC<{onNext?: () => void}> = ({onNext}) => {
    return (
        <div>
            <div className='flex items-center justify-between'>
                <Heading level={3}>Getting paid</Heading>
                <StripeVerified />
            </div>
            <div className='mb-7 mt-6'>
                Stripe is our exclusive direct payments partner. Ghost collects <strong>no fees</strong> on any payments! If you don’t have a Stripe account yet, you can <a className='underline' href="https://stripe.com" rel="noopener noreferrer" target="_blank">sign up here</a>.
            </div>
            <StripeButton label={<>I have a Stripe account, let&apos;s go &rarr;</>} onClick={onNext} />
        </div>
    );
};

const Connect: React.FC = () => {
    const [submitEnabled, setSubmitEnabled] = useState(false);
    const [token, setToken] = useState('');
    const [testMode, setTestMode] = useState(false);
    const [error, setError] = useState('');

    const {refetch: fetchActiveTiers} = useBrowseTiers({
        searchParams: {filter: 'type:paid+active:true'},
        enabled: false
    });
    const {mutateAsync: editTier} = useEditTier();
    const {mutateAsync: editSettings} = useEditSettings();
    const handleError = useHandleError();

    const onTokenChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
        setToken(event.target.value);
        setSubmitEnabled(Boolean(event.target.value));
    };

    const saveTier = async () => {
        const {data} = await fetchActiveTiers();
        const tier = data?.pages[0].tiers[0];

        if (tier) {
            tier.monthly_price = 500;
            tier.yearly_price = 5000;
            tier.currency = 'USD';

            let pollTimeout = 0;
            /** To allow Stripe config to be ready in backend, we poll the save tier request */
            while (pollTimeout < RETRY_PRODUCT_SAVE_MAX_POLL) {
                await new Promise((resolve) => {
                    setTimeout(resolve, RETRY_PRODUCT_SAVE_POLL_LENGTH);
                });

                try {
                    await editTier(tier);
                    break;
                } catch (e) {
                    if (e instanceof JSONError && e.data?.errors?.[0].code === 'STRIPE_NOT_CONFIGURED') {
                        pollTimeout += RETRY_PRODUCT_SAVE_POLL_LENGTH;
                        // no-op: will try saving again as stripe is not ready
                        continue;
                    } else {
                        handleError(e);
                        return;
                    }
                }
            }
        }
    };

    const onSubmit = async () => {
        setError('');

        if (token) {
            try {
                await editSettings([
                    {key: 'stripe_connect_integration_token', value: token}
                ]);

                await saveTier();

                await editSettings([
                    {key: 'portal_plans', value: JSON.stringify(['free', 'monthly', 'yearly'])}
                ]);
            } catch (e) {
                if (e instanceof JSONError && e.data?.errors) {
                    setError('Invalid secure key');
                    return;
                } else {
                    handleError(e);
                    return;
                }
            }
        } else {
            setError('Please enter a secure key');
        }
    };

    const {apiRoot} = getGhostPaths();
    const stripeConnectUrl = `${apiRoot}/members/stripe_connect?mode=${testMode ? 'test' : 'live'}`;

    return (
        <div>
            <div className='mb-6 flex items-center justify-between'>
                <Heading level={3}>Connect with Stripe</Heading>
                <Toggle
                    direction='rtl'
                    label='Test mode'
                    labelClasses={`text-sm translate-y-[1px] ${testMode ? 'text-[#EC6803]' : 'text-grey-800'}`}
                    toggleBg='stripetest'
                    onChange={e => setTestMode(e.target.checked)}
                />
            </div>
            <Heading level={6} grey>Step 1 — <span className='text-black dark:text-white'>Generate secure key</span></Heading>
            <div className='mb-4 mt-2'>
                Click on the <strong>“Connect with Stripe”</strong> button to generate a secure key that connects your Ghost site with Stripe.
            </div>
            <StripeButton href={stripeConnectUrl} tag='a' target='_blank' />
            <Heading className='mb-2 mt-8' level={6} grey>Step 2 — <span className='text-black dark:text-white'>Paste secure key</span></Heading>
            <TextArea clearBg={false} error={Boolean(error)} hint={error || undefined} placeholder='Paste your secure key here' onChange={onTokenChange}></TextArea>
            {submitEnabled && <Button className='mt-5' color='green' label='Save Stripe settings' onClick={onSubmit} />}
        </div>
    );
};

const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => {
    const {settings} = useGlobalData();
    const [stripeConnectAccountName, stripeConnectLivemode] = getSettingValues(settings, ['stripe_connect_display_name', 'stripe_connect_livemode']);

    const {refetch: fetchMembers, isFetching: isFetchingMembers} = useBrowseMembers({
        searchParams: {filter: 'status:paid', limit: '0'},
        enabled: false
    });

    const {mutateAsync: deleteStripeSettings} = useDeleteStripeSettings();
    const handleError = useHandleError();

    const openDisconnectStripeModal = async () => {
        const {data} = await fetchMembers();
        const hasActiveStripeSubscriptions = Boolean(data?.meta?.pagination.total);

        // const hasActiveStripeSubscriptions = false; //...
        // this.ghostPaths.url.api('/members/') + '?filter=status:paid&limit=0';
        NiceModal.show(ConfirmationModal, {
            title: 'Disconnect Stripe',
            prompt: (hasActiveStripeSubscriptions ? 'Cannot disconnect while there are members with active Stripe subscriptions.' : <>You&lsquo;re about to disconnect your Stripe account {stripeConnectAccountName}
                from this site. This will automatically turn off paid memberships on this site.</>),
            okLabel: hasActiveStripeSubscriptions ? '' : 'Disconnect',
            onOk: async (modal) => {
                try {
                    await deleteStripeSettings(null);
                    modal?.remove();
                    onClose?.();
                } catch (e) {
                    handleError(e);
                }
            }
        });
    };

    return (
        <section>
            <div className='flex items-center justify-between'>
                <Button color='red' disabled={isFetchingMembers} icon='link-broken' iconColorClass='text-red' label='Disconnect' link onClick={openDisconnectStripeModal} />
                <Button icon='close' iconColorClass='dark:text-white' label='Close' size='sm' hideLabel link onClick={onClose} />
            </div>
            <div className='my-20 flex flex-col items-center'>
                <div className='relative h-20 w-[200px]'>
                    <img alt='Ghost Logo' className='absolute left-10 h-16 w-16' src={GhostLogo} />
                    <img alt='Stripe Logo' className='absolute right-10 h-16 w-16 rounded-2xl shadow-[-1.5px_0_0_1.5px_#fff] dark:shadow-[-1.5px_0_0_1.5px_black]' src={StripeLogo} />
                </div>
                <Heading className='text-center' level={3}>You are connected with Stripe!{stripeConnectLivemode ? null : ' (Test mode)'}</Heading>
                <div className='mt-1'>Connected to <strong>{stripeConnectAccountName ? stripeConnectAccountName : 'Test mode'}</strong></div>
            </div>
            <div className='flex flex-col items-center'>
                <Heading level={6}>Read next</Heading>
                <a className='w-100 mt-5 flex flex-col items-stretch justify-between rounded-sm border border-grey-200 transition-all hover:border-grey-400 dark:border-grey-900 md:flex-row' href="https://ghost.org/resources/managing-your-stripe-account/?ref=admin" rel="noopener noreferrer" target="_blank">
                    <div className='order-2 p-4 md:order-1'>
                        <div className='font-bold'>How to setup and manage your Stripe account</div>
                        <div className='mt-1 text-sm text-grey-800 dark:text-grey-500'>Learn how to configure your Stripe account to work with Ghost, from custom branding to payment receipt emails.</div>
                        <div className='mt-3 flex items-center gap-1 text-sm text-grey-800 dark:text-grey-500'>
                            <img alt='Ghost Logo' className='h-4 w-4' src={GhostLogoPink} />
                            <strong>Ghost Resources</strong>
                            <span>&middot;</span>
                            <span>by Kym Ellis</span>
                        </div>
                    </div>
                    <div className='order-1 hidden w-[200px] shrink-0 items-center justify-center overflow-hidden md:!visible md:order-2 md:!flex'>
                        <img alt="Bookmark Thumb" className='min-h-full min-w-full shrink-0' src={BookmarkThumb} />
                    </div>
                </a>
            </div>
        </section>
    );
};

const Direct: React.FC<{onClose: () => void}> = ({onClose}) => {
    const {localSettings, updateSetting, handleSave, saveState} = useSettingGroup();
    const [publishableKey, secretKey] = getSettingValues(localSettings, ['stripe_publishable_key', 'stripe_secret_key']);

    const onSubmit = async () => {
        try {
            toast.remove();
            await handleSave();
            onClose();
        } catch (e) {
            if (e instanceof JSONError) {
                showToast({
                    title: 'Failed to save settings',
                    type: 'error',
                    message: 'Check you copied both keys correctly'
                });
                return;
            }

            throw e;
        }
    };

    return (
        <div>
            <Heading level={3}>Connect Stripe</Heading>
            <Form marginBottom={false} marginTop>
                <TextField title='Publishable key' value={publishableKey?.toString()} onChange={e => updateSetting('stripe_publishable_key', e.target.value)} />
                <TextField title='Secure key' value={secretKey?.toString()} onChange={e => updateSetting('stripe_secret_key', e.target.value)} />
                <Button className='mt-5' color='green' disabled={saveState === 'saving'} label='Save Stripe settings' onClick={onSubmit} />
            </Form>
        </div>
    );
};

const StripeConnectModal: React.FC = () => {
    const {config, settings} = useGlobalData();
    const stripeConnectAccountId = getSettingValue(settings, 'stripe_connect_account_id');
    const {updateRoute} = useRouting();
    const [step, setStep] = useState<'start' | 'connect'>('start');
    const mainModal = useModal();

    const startFlow = () => {
        setStep('connect');
    };

    const close = () => {
        mainModal.remove();
        updateRoute('tiers');
    };

    let contents;

    if (config?.stripeDirect || (
        // Still show Stripe Direct to allow disabling the keys if the config was turned off but stripe direct is still set up
        checkStripeEnabled(settings || [], config || {}) && !stripeConnectAccountId
    )) {
        contents = <Direct onClose={close} />;
    } else if (stripeConnectAccountId) {
        contents = <Connected onClose={close} />;
    } else if (step === 'start') {
        contents = <Start onNext={startFlow} />;
    } else {
        contents = <Connect />;
    }

    return <Modal
        afterClose={() => {
            updateRoute('tiers');
        }}
        cancelLabel=''
        footer={<div className='mt-8'></div>}
        testId='stripe-modal'
        title=''
        width={stripeConnectAccountId ? 740 : 520}
        hideXOnMobile
    >
        {contents}
    </Modal>;
};

export default NiceModal.create(StripeConnectModal);