TryGhost/Ghost

View on GitHub
apps/portal/src/components/pages/SignupPage.js

Summary

Maintainability
F
6 days
Test Coverage
import React from 'react';
import ActionButton from '../common/ActionButton';
import AppContext from '../../AppContext';
import CloseButton from '../common/CloseButton';
import SiteTitleBackButton from '../common/SiteTitleBackButton';
import NewsletterSelectionPage from './NewsletterSelectionPage';
import ProductsSection from '../common/ProductsSection';
import InputForm from '../common/InputForm';
import {ValidateInputForm} from '../../utils/form';
import {getSiteProducts, getSitePrices, hasOnlyFreePlan, isInviteOnlySite, freeHasBenefitsOrDescription, hasOnlyFreeProduct, getFreeProductBenefits, getFreeTierDescription, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed} from '../../utils/helpers';
import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg';
import {interceptAnchorClicks} from '../../utils/links';

export const SignupPageStyles = `
.gh-portal-back-sitetitle {
    position: absolute;
    top: 35px;
    left: 32px;
}

.gh-portal-back-sitetitle .gh-portal-btn {
    padding: 0;
    border: 0;
    font-size: 1.5rem;
    height: auto;
    line-height: 1em;
    color: var(--grey1);
}

.gh-portal-popup-wrapper:not(.full-size) .gh-portal-back-sitetitle,
.gh-portal-popup-wrapper.preview .gh-portal-back-sitetitle {
    display: none;
}

.gh-portal-signup-logo {
    position: relative;
    display: block;
    background-position: 50%;
    background-size: cover;
    border-radius: 2px;
    width: 60px;
    height: 60px;
    margin: 12px 0 10px;
}

.gh-portal-signup-header,
.gh-portal-signin-header {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 0 32px;
    margin-bottom: 32px;
}

.gh-portal-popup-wrapper.full-size .gh-portal-signup-header {
    margin-top: 32px;
}

.gh-portal-signup-header .gh-portal-main-title,
.gh-portal-signin-header .gh-portal-main-title {
    margin-top: 12px;
}

.gh-portal-signup-logo + .gh-portal-main-title {
    margin: 4px 0 0;
}

.gh-portal-signup-header .gh-portal-main-subtitle {
    font-size: 1.5rem;
    text-align: center;
    line-height: 1.45em;
    margin: 4px 0 0;
    color: var(--grey3);
}

.gh-portal-logged-out-form-container {
    width: 100%;
    max-width: 420px;
    margin: 0 auto;
}

.signup .gh-portal-input-section:last-of-type {
    margin-bottom: 40px;
}

.gh-portal-signup-message {
    display: flex;
    justify-content: center;
    color: var(--grey4);
    font-size: 1.5rem;
    margin: 16px 0 0;
}

.gh-portal-signup-message,
.gh-portal-signup-message * {
    z-index: 9999;
}

.full-size .gh-portal-signup-message {
    margin: 24px 0 40px;
}

@media (max-width: 480px) {
    .preview .gh-portal-products + .gh-portal-signup-message {
        margin-bottom: 40px;
    }
}

.gh-portal-signup-message button {
    font-size: 1.4rem;
    font-weight: 600;
    margin-left: 4px !important;
    margin-bottom: -1px;
}

.gh-portal-signup-message button span {
    display: inline-block;
    padding-bottom: 2px;
    margin-bottom: -2px;
}

.gh-portal-content.signup.invite-only {
    background: none;
}

footer.gh-portal-signup-footer,
footer.gh-portal-signin-footer {
    display: flex;
    flex-direction: column;
    align-items: center;
    position: relative;
    padding-top: 24px;
    height: unset;
}

.gh-portal-content.signup,
.gh-portal-content.signin {
    max-height: unset !important;
    padding-bottom: 0;
}

.gh-portal-content.signin {
    padding-bottom: 4px;
}

.gh-portal-content.signup .gh-portal-section {
    margin-bottom: 0;
}

.gh-portal-content.signup.single-field {
    margin-bottom: 4px;
}

.gh-portal-content.signup.single-field .gh-portal-input,
.gh-portal-content.signin .gh-portal-input {
    margin-bottom: 12px;
}

.gh-portal-content.signup.single-field + .gh-portal-signup-footer,
footer.gh-portal-signin-footer {
    padding-top: 12px;
}

.gh-portal-content.signin .gh-portal-section {
    margin-bottom: 0;
}

footer.gh-portal-signup-footer.invite-only {
    height: unset;
}

footer.gh-portal-signup-footer.invite-only .gh-portal-signup-message {
    margin-top: 0;
}

.gh-portal-invite-only-notification, .gh-portal-members-disabled-notification {
    margin: 8px 32px 24px;
    padding: 0;
    text-align: center;
    color: var(--grey2);
}

.gh-portal-icon-invitation {
    width: 44px;
    height: 44px;
    margin: 12px 0 2px;
}

.gh-portal-popup-wrapper.full-size .gh-portal-popup-container.preview footer.gh-portal-signup-footer {
    padding-bottom: 32px;
}

.gh-portal-invite-only-notification + .gh-portal-signup-message {
    margin-bottom: 12px;
}

.gh-portal-free-trial-notification {
    max-width: 480px;
    text-align: center;
    margin: 24px auto;
    color: var(--grey4);
}

.gh-portal-signup-terms-wrapper {
    width: 100%;
    max-width: 420px;
    margin: 0 auto;
}

.signup.single-field .gh-portal-signup-terms-wrapper {
    margin-top: 12px;
}

.signup.single-field .gh-portal-products:not(:has(.gh-portal-product-card)) {
    margin-top: -16px;
}

.gh-portal-signup-terms {
    margin: -16px 0 36px;
}

.gh-portal-signup-terms-wrapper.free-only .gh-portal-signup-terms {
    margin: -16px 0 24px;
}

.gh-portal-products:has(.gh-portal-product-card) + .gh-portal-signup-terms-wrapper.free-only {
    margin: 20px auto 0 !important;
}

.gh-portal-signup-terms label {
    position: relative;
    display: flex;
    gap: 10px;
    cursor: pointer;
}

.gh-portal-signup-terms input {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    display: none;
}

.gh-portal-signup-terms .checkbox {
    position: relative;
    top: 1px;
    flex-shrink: 0;
    display: inline-block;
    float: left;
    width: 18px;
    height: 18px;
    margin: 1px 0 0;
    background: var(--white);
    border: 1px solid var(--grey10);
    border-radius: 4px;
    transition: background 0.15s ease-in-out, border-color 0.15s ease-in-out;
}

.gh-portal-signup-terms label:hover input:not(:checked) + .checkbox {
    border-color: var(--grey9);
}

.gh-portal-signup-terms .checkbox:before {
    content: "";
    position: absolute;
    top: 4px;
    left: 3px;
    width: 10px;
    height: 6px;
    border: 2px solid var(--white);
    border-top: none;
    border-right: none;
    opacity: 0;
    transition: opacity 0.15s ease-in-out;
    transform: rotate(-45deg);
}

.gh-portal-signup-terms input:checked + .checkbox {
    border-color: var(--black);
    background: var(--black);
}

.gh-portal-signup-terms input:checked + .checkbox:before {
    opacity: 1;
}

.gh-portal-signup-terms.gh-portal-error .checkbox,
.gh-portal-signup-terms.gh-portal-error label:hover input:not(:checked) + .checkbox {
    border: 1px solid var(--red);
    box-shadow: 0 0 0 3px rgb(240, 37, 37, .15);
}

.gh-portal-signup-terms.gh-portal-error input:checked + .checkbox {
    box-shadow: none;
}

.gh-portal-signup-terms-content {
    color: var(--grey4);
    font-size: 1.5rem;
    line-height: 1.5em;
}

.gh-portal-signup-terms-content p {
    margin-bottom: 0;
}

.gh-portal-error .gh-portal-signup-terms-content {
    line-height: 1.5em;
}

.gh-portal-signup-terms-content a {
    color: var(--brandcolor);
    font-weight: 500;
    text-decoration: none;
}

@media (min-width: 480px) {

}

@media (max-width: 480px) {
    .gh-portal-signup-logo {
        width: 48px;
        height: 48px;
    }
}

@media (min-width: 480px) and (max-width: 820px) {
    .gh-portal-powered.outside {
        left: 50%;
        transform: translateX(-50%);
    }
}
`;

class SignupPage extends React.Component {
    static contextType = AppContext;

    constructor(props) {
        super(props);
        this.state = {
            name: '',
            email: '',
            plan: 'free',
            showNewsletterSelection: false,
            termsCheckboxChecked: false
        };
    }

    componentDidMount() {
        const {member} = this.context;
        if (member) {
            this.context.onAction('switchPage', {
                page: 'accountHome'
            });
        }

        // Handle the default plan if not set
        this.handleSelectedPlan();
    }

    componentDidUpdate() {
        this.handleSelectedPlan();
    }

    handleSelectedPlan() {
        const {site, pageQuery} = this.context;
        const prices = getSitePrices({site, pageQuery});

        const selectedPriceId = this.getSelectedPriceId(prices, this.state.plan);
        if (selectedPriceId !== this.state.plan) {
            this.setState({
                plan: selectedPriceId
            });
        }
    }

    componentWillUnmount() {
        clearTimeout(this.timeoutId);
    }

    getFormErrors(state) {
        const checkboxRequired = this.context.site.portal_signup_checkbox_required && this.context.site.portal_signup_terms_html;
        const checkboxError = checkboxRequired && !state.termsCheckboxChecked;

        return {
            ...ValidateInputForm({fields: this.getInputFields({state})}),
            checkbox: checkboxError
        };
    }

    doSignup() {
        this.setState((state) => {
            return {
                errors: this.getFormErrors(state)
            };
        }, () => {
            const {site, onAction} = this.context;
            const {name, email, plan, errors} = this.state;
            const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0);
            if (!hasFormErrors) {
                if (hasMultipleNewsletters({site})) {
                    this.setState({
                        showNewsletterSelection: true,
                        pageData: {name, email, plan},
                        errors: {}
                    });
                } else {
                    this.setState({
                        errors: {}
                    });
                    onAction('signup', {name, email, plan});
                }
            }
        });
    }

    handleSignup(e) {
        e.preventDefault();
        this.doSignup();
    }

    handleChooseSignup(e, plan) {
        e.preventDefault();
        this.setState({plan}, () => {
            this.doSignup();
        });
    }

    handleInputChange(e, field) {
        const fieldName = field.name;
        const value = e.target.value;
        this.setState({
            [fieldName]: value
        });
    }

    handleSelectPlan = (e, priceId) => {
        e && e.preventDefault();
        // Hack: React checkbox gets out of sync with dom state with instant update
        this.timeoutId = setTimeout(() => {
            this.setState(() => {
                return {
                    plan: priceId
                };
            });
        }, 5);
    };

    onKeyDown(e) {
        // Handles submit on Enter press
        if (e.keyCode === 13){
            this.handleSignup(e);
        }
    }

    getSelectedPriceId(prices = [], selectedPriceId) {
        if (!prices || prices.length === 0) {
            return 'free';
        }
        const hasSelectedPlan = prices.some((p) => {
            return p.id === selectedPriceId;
        });

        if (!hasSelectedPlan) {
            return prices[0].id || 'free';
        }

        return selectedPriceId;
    }

    getInputFields({state, fieldNames}) {
        const {site: {portal_name: portalName}, t} = this.context;

        const errors = state.errors || {};
        const fields = [
            {
                type: 'email',
                value: state.email,
                placeholder: 'jamie@example.com',
                label: t('Email'),
                name: 'email',
                required: true,
                tabindex: 2,
                errorMessage: errors.email || ''
            }
        ];

        /** Show Name field if portal option is set*/
        if (portalName) {
            fields.unshift({
                type: 'text',
                value: state.name,
                placeholder: 'Jamie Larson',
                label: t('Name'),
                name: 'name',
                required: true,
                tabindex: 1,
                errorMessage: errors.name || ''
            });
        }
        fields[0].autoFocus = true;
        if (fieldNames && fieldNames.length > 0) {
            return fields.filter((f) => {
                return fieldNames.includes(f.name);
            });
        }
        return fields;
    }

    renderSignupTerms() {
        const {site} = this.context;

        if (site.portal_signup_terms_html === null || site.portal_signup_terms_html === '') {
            return null;
        }

        const handleCheckboxChange = (e) => {
            this.setState({
                termsCheckboxChecked: e.target.checked
            });
        };

        const termsText = (
            <div className="gh-portal-signup-terms-content"
                dangerouslySetInnerHTML={{__html: site.portal_signup_terms_html}}
            ></div>
        );

        const signupTerms = site.portal_signup_checkbox_required ? (
            <label>
                <input
                    type="checkbox"
                    checked={!!this.state.termsCheckboxChecked}
                    required={true}
                    onChange={handleCheckboxChange}
                />
                <span className="checkbox"></span>
                {termsText}
            </label>
        ) : termsText;

        const errorClassName = this.state.errors?.checkbox ? 'gh-portal-error' : '';

        const className = `gh-portal-signup-terms ${errorClassName}`;

        return (
            <div className={className} onClick={interceptAnchorClicks}>
                {signupTerms}
            </div>
        );
    }

    renderSubmitButton() {
        const {action, site, brandColor, pageQuery, t} = this.context;

        if (isInviteOnlySite({site, pageQuery})) {
            return null;
        }

        let label = t('Continue');
        const showOnlyFree = pageQuery === 'free';

        if (hasOnlyFreePlan({site}) || showOnlyFree) {
            label = t('Sign up');
        } else {
            return null;
        }

        let isRunning = false;
        if (action === 'signup:running') {
            label = t('Sending...');
            isRunning = true;
        }
        let retry = false;
        if (action === 'signup:failed') {
            label = t('Retry');
            retry = true;
        }

        const disabled = (action === 'signup:running') ? true : false;
        return (
            <ActionButton
                style={{width: '100%'}}
                retry={retry}
                onClick={e => this.handleSignup(e)}
                disabled={disabled}
                brandColor={brandColor}
                label={label}
                isRunning={isRunning}
                tabIndex='3'
            />
        );
    }

    renderProducts() {
        const {site, pageQuery, t} = this.context;
        const products = getSiteProducts({site, pageQuery});
        const errors = this.state.errors || {};
        const priceErrors = {};

        // If we have at least one error, set an error message for the current selected plan
        if (Object.keys(errors).length > 0 && this.state.plan) {
            priceErrors[this.state.plan] = t('Please fill in required fields');
        }

        return (
            <>
                <ProductsSection
                    handleChooseSignup={(...args) => this.handleChooseSignup(...args)}
                    products={products}
                    onPlanSelect={this.handleSelectPlan}
                    errors={priceErrors}
                />
            </>
        );
    }

    renderFreeTrialMessage() {
        const {site, t, pageQuery} = this.context;
        if (hasFreeTrialTier({site, pageQuery}) && !isInviteOnlySite({site})) {
            return (
                <p className='gh-portal-free-trial-notification' data-testid="free-trial-notification-text">
                    {t('After a free trial ends, you will be charged the regular price for the tier you\'ve chosen. You can always cancel before then.')}
                </p>
            );
        }
        return null;
    }

    renderLoginMessage() {
        const {brandColor, onAction, t} = this.context;
        return (
            <div>
                {this.renderFreeTrialMessage()}
                <div className='gh-portal-signup-message'>
                    <div>{t('Already a member?')}</div>
                    <button
                        data-test-button='signin-switch'
                        className='gh-portal-btn gh-portal-btn-link'
                        style={{color: brandColor}}
                        onClick={() => onAction('switchPage', {page: 'signin'})}
                    >
                        <span>{t('Sign in')}</span>
                    </button>
                </div>
            </div>
        );
    }

    renderForm() {
        const fields = this.getInputFields({state: this.state});
        const {site, pageQuery, t} = this.context;

        if (this.state.showNewsletterSelection) {
            return (
                <NewsletterSelectionPage
                    pageData={this.state.pageData}
                    onBack={() => {
                        this.setState({
                            showNewsletterSelection: false
                        });
                    }}
                />
            );
        }

        if (isInviteOnlySite({site, pageQuery})) {
            return (
                <section>
                    <div className='gh-portal-section'>
                        <p
                            className='gh-portal-invite-only-notification'
                            data-testid="invite-only-notification-text"
                        >
                            {t('This site is invite-only, contact the owner for access.')}
                        </p>
                        {this.renderLoginMessage()}
                    </div>
                </section>
            );
        }

        if (!isSignupAllowed({site})) {
            return (
                <section>
                    <div className='gh-portal-section'>
                        <p
                            className='gh-portal-members-disabled-notification'
                            data-testid="members-disabled-notification-text"
                        >
                            {t('Memberships unavailable, contact the owner for access.')}
                        </p>
                    </div>
                </section>
            );
        }

        const freeBenefits = getFreeProductBenefits({site});
        const freeDescription = getFreeTierDescription({site});
        const showOnlyFree = pageQuery === 'free';
        const hasOnlyFree = hasOnlyFreeProduct({site}) || showOnlyFree;
        const sticky = !showOnlyFree && (freeBenefits.length || freeDescription);

        const signupTerms = this.renderSignupTerms();

        return (
            <section className="gh-portal-signup">
                <div className='gh-portal-section'>
                    <div className='gh-portal-logged-out-form-container'>
                        <InputForm
                            fields={fields}
                            onChange={(e, field) => this.handleInputChange(e, field)}
                            onKeyDown={e => this.onKeyDown(e)}
                        />
                    </div>
                    <div>
                        {(hasOnlyFree ?
                            <>
                                {this.renderProducts()}
                                {signupTerms &&
                                <div className='gh-portal-signup-terms-wrapper free-only'>
                                    {signupTerms}
                                </div>
                                }
                            </> :
                            <>
                                {signupTerms &&
                                <div className='gh-portal-signup-terms-wrapper'>
                                    {signupTerms}
                                </div>
                                }
                                {this.renderProducts()}
                            </>)}

                        {(hasOnlyFree ?
                            <div className={'gh-portal-btn-container' + (sticky ? ' sticky m24' : '')}>
                                <div className='gh-portal-logged-out-form-container'>
                                    {this.renderSubmitButton()}
                                    {this.renderLoginMessage()}
                                </div>
                            </div>
                            :
                            this.renderLoginMessage())}
                    </div>
                </div>
            </section>
        );
    }

    renderSiteIcon() {
        const {site, pageQuery} = this.context;
        const siteIcon = site.icon;

        if (siteIcon) {
            return (
                <img className='gh-portal-signup-logo' src={siteIcon} alt={site.title} />
            );
        } else if (isInviteOnlySite({site, pageQuery})) {
            return (
                <InvitationIcon className='gh-portal-icon gh-portal-icon-invitation' />
            );
        } else if (!isSignupAllowed({site})) {
            return (
                <InvitationIcon className='gh-portal-icon gh-portal-icon-invitation' />
            );
        }
        return null;
    }

    renderFormHeader() {
        const {site} = this.context;
        const siteTitle = site.title || '';
        return (
            <header className='gh-portal-signup-header'>
                {this.renderSiteIcon()}
                <h1 className="gh-portal-main-title" data-testid='site-title-text'>{siteTitle}</h1>
            </header>
        );
    }

    getClassNames() {
        const {site, pageQuery} = this.context;
        const plansData = getSitePrices({site, pageQuery});
        const fields = this.getInputFields({state: this.state});
        let sectionClass = '';
        let footerClass = '';

        if (plansData.length <= 1 || isInviteOnlySite({site})) {
            if ((plansData.length === 1 && plansData[0].type === 'free') || isInviteOnlySite({site, pageQuery})) {
                sectionClass = freeHasBenefitsOrDescription({site}) ? 'singleplan' : 'noplan';
                if (fields.length === 1) {
                    sectionClass = 'single-field';
                }
                if (isInviteOnlySite({site})) {
                    footerClass = 'invite-only';
                    sectionClass = 'invite-only';
                }
            } else {
                sectionClass = 'singleplan';
            }
        }

        return {sectionClass, footerClass};
    }

    render() {
        let {sectionClass} = this.getClassNames();
        return (
            <>
                <div className='gh-portal-back-sitetitle'>
                    <SiteTitleBackButton
                        onBack={() => {
                            if (this.state.showNewsletterSelection) {
                                this.setState({
                                    showNewsletterSelection: false
                                });
                            } else {
                                this.context.onAction('closePopup');
                            }
                        }}
                    />
                </div>
                <CloseButton />
                <div className={'gh-portal-content signup ' + sectionClass}>
                    {this.renderFormHeader()}
                    {this.renderForm()}
                </div>
            </>
        );
    }
}

export default SignupPage;