TryGhost/Ghost

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

Summary

Maintainability
D
1 day
Test Coverage
import AppContext from '../../AppContext';
import {useContext, useState, useEffect, useCallback, useMemo} from 'react';
import CloseButton from '../common/CloseButton';
import {clearURLParams} from '../../utils/notifications';
import LoadingPage from './LoadingPage';
import {ReactComponent as ArrowIcon} from '../../images/icons/arrow-top-right.svg';
import {ReactComponent as LoaderIcon} from '../../images/icons/loader.svg';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/check-circle.svg';

import {getRefDomain} from '../../utils/helpers';

export const RecommendationsPageStyles = `
    .gh-portal-recommendations-header .gh-portal-main-title {
        padding: 0 32px;
        text-wrap: balance;
    }

    .gh-portal-recommendation-item {
        min-height: 38px;
    }

    .gh-portal-recommendation-item .gh-portal-list-detail {
        padding: 4px 24px 4px 0px;
    }

    .gh-portal-recommendation-item-header {
    display: flex;
    align-items: center;
    gap: 10px;
    cursor: pointer;
    }

    .gh-portal-recommendation-item-favicon {
    width: 20px;
    height: 20px;
    border-radius: 3px;
    }

    .gh-portal-recommendations-header {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-bottom: 20px;
    }

    .gh-portal-recommendations-description {
    text-align: center;
    }

    .gh-portal-recommendation-description-container {
        position: relative;
    }

    .gh-portal-recommendation-item .gh-portal-recommendation-description-container p {
        font-size: 1.35rem;
        padding-left: 30px;
        font-weight: 400;
        letter-spacing: 0.1px;
        margin-top: 4px;
    }

    .gh-portal-recommendation-description-hidden {
        visibility: hidden;
    }

    .gh-portal-recommendation-item .gh-portal-list-detail {
    transition: 0.2s ease-in-out opacity;
    }

    .gh-portal-list-detail:hover {
    cursor: pointer;
    opacity: 0.8;
    }

    .gh-portal-recommendation-arrow-icon {
    height: 12px;
    opacity: 0;
    margin-left: -6px;
    transition: 0.2s ease-in opacity;
    }

    .gh-portal-recommendation-arrow-icon path {
    stroke-width: 3px;
    stroke: #555;
    }

    .gh-portal-recommendation-item .gh-portal-list-detail:hover .gh-portal-recommendation-arrow-icon {
    opacity: 0.8;
    }

    .gh-portal-recommendation-item .gh-portal-btn-list {
        height: 28px;
    }

    .gh-portal-recommendation-subscribed {
        display: flex;
        padding-left: 30px;
        align-items: center;
        gap: 4px;
        font-size: 1.35rem;
        font-weight: 400;
        letter-spacing: 0.1px;
        line-height: 1.3em;
        animation: 0.5s ease-in-out fadeIn;
    }

    .gh-portal-recommendation-subscribed.with-description {
        position: absolute;
    }

    .gh-portal-recommendation-subscribed.without-description {
        margin-top: 5px;
    }

    .gh-portal-recommendation-subscribed span {
        color: var(--grey6);
    }

    .gh-portal-recommendation-checkmark-icon {
        height: 16px;
        width: 16px;
        padding: 0 2px;
        color: #30cf43;
    }

    .gh-portal-recommendation-item .gh-portal-loadingicon {
        position: relative !important;
        height: 24px;
    }

    .gh-portal-recommendation-item-action {
        min-height: 28px;
    }

    .gh-portal-popup-container.recommendations .gh-portal-action-footer

    .gh-portal-btn-recommendations-later {
        margin: 8px auto 24px;
        color: var(--grey6);
        font-weight: 400;
    }
`;

// Fisher-Yates shuffle
// @see https://stackoverflow.com/a/2450976/3015595
const shuffleRecommendations = (array) => {
    let currentIndex = array.length;
    let randomIndex;

    while (currentIndex > 0) {
        randomIndex = Math.floor(Math.random() * currentIndex);
        currentIndex -= 1;

        [array[currentIndex], array[randomIndex]] = [
            array[randomIndex], array[currentIndex]];
    }

    return array;
};

const RecommendationIcon = ({title, favicon, featuredImage}) => {
    const [icon, setIcon] = useState(favicon || featuredImage);

    const hideIcon = () => {
        setIcon(null);
    };

    if (!icon) {
        return <div className="gh-portal-recommendation-item-favicon"></div>;
    }

    return (<img className="gh-portal-recommendation-item-favicon" src={icon} alt={title} onError={hideIcon} />);
};

const openTab = (url) => {
    const tab = window.open(url, '_blank');
    if (tab) {
        tab.focus();
    } else {
        // Safari fix after async operation / failed to create a new tab
        window.location.href = url;
    }
};

const RecommendationItem = (recommendation) => {
    const {t, onAction, member, site} = useContext(AppContext);
    const {title, url, description, favicon, one_click_subscribe: oneClickSubscribe, featured_image: featuredImage} = recommendation;
    const allowOneClickSubscribe = member && oneClickSubscribe;
    const [subscribed, setSubscribed] = useState(false);
    const [clicked, setClicked] = useState(false);
    const [loading, setLoading] = useState(false);
    const outboundLinkTagging = site.outbound_link_tagging ?? false;

    const refUrl = useMemo(() => {
        if (!outboundLinkTagging) {
            return url;
        }
        try {
            const ref = new URL(url);

            if (ref.searchParams.has('ref') || ref.searchParams.has('utm_source') || ref.searchParams.has('source')) {
                // Don't overwrite + keep existing source attribution
                return url;
            }
            ref.searchParams.set('ref', getRefDomain());
            return ref.toString();
        } catch (_) {
            return url;
        }
    }, [url, outboundLinkTagging]);

    const visitHandler = useCallback(() => {
        // Open url in a new tab
        openTab(refUrl);

        if (!clicked) {
            onAction('trackRecommendationClicked', {recommendationId: recommendation.id});
            setClicked(true);
        }
    }, [refUrl, recommendation.id, clicked]);

    const oneClickSubscribeHandler = useCallback(async () => {
        try {
            setLoading(true);
            await onAction('oneClickSubscribe', {
                siteUrl: url,
                throwErrors: true
            });
            onAction('trackRecommendationSubscribed', {recommendationId: recommendation.id});
            setSubscribed(true);
        } catch (_) {
            // Open portal signup page
            const signupUrl = new URL('#/portal/signup', refUrl);

            // Trigger a visit
            openTab(signupUrl);

            if (!clicked) {
                onAction('trackRecommendationClicked', {recommendationId: recommendation.id});
                setClicked(true);
            }
        }
        setLoading(false);
    }, [setSubscribed, url, refUrl, recommendation.id, clicked]);

    const clickHandler = useCallback((e) => {
        if (loading) {
            return;
        }
        if (allowOneClickSubscribe) {
            oneClickSubscribeHandler(e);
        } else {
            visitHandler(e);
        }
    }, [loading, allowOneClickSubscribe, oneClickSubscribeHandler, visitHandler]);

    return (
        <section className="gh-portal-recommendation-item">
            <div className="gh-portal-list-detail gh-portal-list-big" onClick={visitHandler}>
                <div className="gh-portal-recommendation-item-header">
                    <RecommendationIcon title={title} favicon={favicon} featuredImage={featuredImage} />
                    <h3>{title}</h3>
                    <ArrowIcon className="gh-portal-recommendation-arrow-icon" />
                </div>
                <div className="gh-portal-recommendation-description-container">
                    {subscribed && <div className={'gh-portal-recommendation-subscribed ' + (description ? 'with-description' : 'without-description')}><span>{t('Verification link sent, check your inbox')}</span><CheckmarkIcon className="gh-portal-recommendation-checkmark-icon" alt=''/></div>}
                    {description && <p className={subscribed ? 'gh-portal-recommendation-description-hidden' : ''}>{description}</p>}
                </div>
            </div>
            <div className="gh-portal-recommendation-item-action">
                {!subscribed && loading && <span className='gh-portal-recommendations-loading-container'><LoaderIcon className={'gh-portal-loadingicon dark'} /></span>}
                {!subscribed && !loading && allowOneClickSubscribe && <button type="button" className="gh-portal-btn gh-portal-btn-list" onClick={clickHandler}>{t('Subscribe')}</button>}
            </div>
        </section>
    );
};

const RecommendationsPage = () => {
    const {api, site, pageData, t, onAction} = useContext(AppContext);
    const {title, icon} = site;
    const {recommendations_enabled: recommendationsEnabled = false} = site;
    const [recommendations, setRecommendations] = useState(null);

    useEffect(() => {
        api.site.recommendations({limit: 100}).then((data) => {
            const withOneClickSubscribe = data.recommendations.filter(recommendation => recommendation.one_click_subscribe);
            const withoutOneClickSubscribe = data.recommendations.filter(recommendation => !recommendation.one_click_subscribe);

            setRecommendations(
                [
                    ...shuffleRecommendations(withOneClickSubscribe),
                    ...shuffleRecommendations(withoutOneClickSubscribe)
                ]
            );
        }).catch((err) => {
            // eslint-disable-next-line no-console
            console.error(err);
        });
    }, []);

    // Show 5 recommendations by default
    const [numToShow, setNumToShow] = useState(5);

    const showAllRecommendations = () => {
        setNumToShow(recommendations.length);
    };

    useEffect(() => {
        return () => {
            if (pageData.signup) {
                const deleteParams = [];
                deleteParams.push('action', 'success');
                clearURLParams(deleteParams);
            }
        };
    }, []);

    const heading = pageData && pageData.signup ? t('Welcome to {{siteTitle}}', {siteTitle: title, interpolation: {escapeValue: false}}) : t('Recommendations');
    const subheading = pageData && pageData.signup ? t('Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.') : t('Here are a few other sites you may enjoy.');

    if (!recommendationsEnabled) {
        return null;
    }

    if (recommendations === null) {
        return <LoadingPage/>;
    }

    return (
        <div className='gh-portal-content with-footer'>
            <CloseButton />
            <div className="gh-portal-recommendations-header">
                {icon && <img className="gh-portal-signup-logo" alt={title} src={icon} />}
                <h1 className="gh-portal-main-title">{heading}</h1>
            </div>
            <p className="gh-portal-recommendations-description">{subheading}</p>

            <div className="gh-portal-list">
                {recommendations.slice(0, numToShow).map((recommendation, index) => (
                    <RecommendationItem key={index} {...recommendation} />
                ))}
            </div>

            {((numToShow < recommendations.length) || (pageData && pageData.signup)) && (
                <footer className='gh-portal-action-footer'>
                    {(numToShow < recommendations.length) && <button className='gh-portal-btn gh-portal-center' style={{width: '100%'}} onClick={showAllRecommendations}>
                        <span>{t('Show all')}</span>
                    </button>}
                    {(pageData && pageData.signup) && <button className='gh-portal-btn gh-portal-center gh-portal-btn-link gh-portal-btn-recommendations-later' style={{width: '100%'}} onClick={showAllRecommendations}>
                        <span onClick={() => onAction('closePopup')}>{t('Maybe later')}</span>
                    </button>}
                </footer>
            )}
        </div>
    );
};

export default RecommendationsPage;