TryGhost/Ghost

View on GitHub
apps/comments-ui/src/components/popups/AddDetailsPopup.tsx

Summary

Maintainability
C
1 day
Test Coverage
import CloseButton from './CloseButton';
import reactStringReplace from 'react-string-replace';
import {Transition} from '@headlessui/react';
import {isMobile} from '../../utils/helpers';
import {useAppContext} from '../../AppContext';
import {useEffect, useRef, useState} from 'react';

type Props = {
    callback: (succeeded: boolean) => void,
    expertiseAutofocus?: boolean
};
const AddDetailsPopup = (props: Props) => {
    const inputNameRef = useRef<HTMLInputElement>(null);
    const inputExpertiseRef = useRef<HTMLInputElement>(null);
    const {dispatchAction, member, accentColor, t} = useAppContext();

    const [name, setName] = useState(member.name ?? '');
    const [expertise, setExpertise] = useState(member.expertise ?? '');

    const maxExpertiseChars = 50;
    let initialExpertiseChars = maxExpertiseChars;
    if (member.expertise) {
        initialExpertiseChars -= member.expertise.length;
    }
    const [expertiseCharsLeft, setExpertiseCharsLeft] = useState(initialExpertiseChars);

    const [error, setError] = useState({name: '', expertise: ''});

    const stopPropagation = (event: Event) => {
        event.stopPropagation();
    };

    const close = (succeeded: boolean) => {
        dispatchAction('closePopup', {});
        props.callback(succeeded);
    };

    const submit = async () => {
        if (name.trim() !== '') {
            await dispatchAction('updateMember', {
                name,
                expertise
            });
            close(true);
        } else {
            setError({name: t('Enter your name'), expertise: ''});
            setName('');
            inputNameRef.current?.focus();
        }
    };

    // using <input autofocus> breaks transitions in browsers. So we need to use a timer
    useEffect(() => {
        if (!isMobile()) {
            const timer = setTimeout(() => {
                if (props.expertiseAutofocus) {
                    inputExpertiseRef.current?.focus();
                } else {
                    inputNameRef.current?.focus();
                }
            }, 200);

            return () => {
                clearTimeout(timer);
            };
        }
    }, [inputNameRef, inputExpertiseRef, props.expertiseAutofocus]);

    const renderExampleProfiles = () => {
        const renderEl = (profile: {name: string, avatar: string, expertise: string}) => {
            return (
                <Transition
                    key={profile.name}
                    enter={`transition duration-200 delay-[400ms] ease-out`}
                    enterFrom="opacity-0 translate-y-2"
                    enterTo="opacity-100 translate-y-0"
                    leave="transition duration-200 ease-in"
                    leaveFrom="opacity-100 translate-y-0"
                    leaveTo="opacity-0 translate-y-2"
                    appear
                >
                    <div className="flex flex-row items-center justify-start gap-3 pr-4">
                        <div className="h-10 w-10 rounded-full border-2 border-white bg-cover bg-no-repeat" style={{backgroundImage: `url(${profile.avatar})`}} />
                        <div className="flex flex-col items-start justify-center">
                            <div className="font-sans text-base font-semibold tracking-tight text-white">
                                {profile.name}
                            </div>
                            <div className="font-sans text-[14px] tracking-tight text-neutral-400">
                                {profile.expertise}
                            </div>
                        </div>
                    </div>
                </Transition>
            );
        };

        const returnable = [];

        // using URLS over real images for avatars as serving JPG images was not optimal (based on discussion with team)
        const exampleProfiles = [
            {avatar: 'https://randomuser.me/api/portraits/men/32.jpg', name: 'James Fletcher', expertise: t('Full-time parent')},
            {avatar: 'https://randomuser.me/api/portraits/women/30.jpg', name: 'Naomi Schiff', expertise: t('Founder @ Acme Inc')},
            {avatar: 'https://randomuser.me/api/portraits/men/4.jpg', name: 'Franz Tost', expertise: t('Neurosurgeon')},
            {avatar: 'https://randomuser.me/api/portraits/women/51.jpg', name: 'Katrina Klosp', expertise: t('Local resident')}
        ];

        for (let i = 0; i < exampleProfiles.length; i++) {
            returnable.push(renderEl(exampleProfiles[i]));
        }

        return returnable;
    };

    const charsText = reactStringReplace(t('{{amount}} characters left'), '{{amount}}', () => {
        return <b>{expertiseCharsLeft}</b>;
    });

    return (
        <div className="shadow-modal relative h-screen w-screen overflow-hidden rounded-none bg-white p-[28px] text-center sm:h-auto sm:w-[720px] sm:rounded-xl sm:p-0" data-testid="profile-modal" onMouseDown={stopPropagation}>
            <div className="flex">
                {!isMobile() &&
                    <div className={`flex w-[40%] flex-col items-center justify-center bg-[#1C1C1C]`}>
                        <div className="mt-[-1px] flex flex-col gap-9">
                            {renderExampleProfiles()}
                        </div>
                    </div>
                }
                <div className={`${isMobile() ? 'w-full' : 'w-[60%]'} p-0 sm:p-8`}>
                    <h1 className="mb-1 text-center font-sans text-[24px] font-bold tracking-tight text-black sm:text-left">{t('Complete your profile')}<span className="hidden sm:inline">.</span></h1>
                    <p className="pr-0 text-center font-sans text-base leading-9 text-neutral-500 sm:pr-10 sm:text-left">{t('Add context to your comment, share your name and expertise to foster a healthy discussion.')}</p>
                    <section className="mt-8 text-left">
                        <div className="mb-2 flex flex-row justify-between">
                            <label className="font-sans text-[1.3rem] font-semibold" htmlFor="comments-name">{t('Name')}</label>
                            <Transition
                                enter="transition duration-300 ease-out"
                                enterFrom="opacity-0"
                                enterTo="opacity-100"
                                leave="transition duration-100 ease-out"
                                leaveFrom="opacity-100"
                                leaveTo="opacity-0"
                                show={!!error.name}
                            >
                                <div className="font-sans text-sm text-red-500">{error.name}</div>
                            </Transition>
                        </div>
                        <input
                            ref={inputNameRef}
                            className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${error.name && 'border-red-500 focus:border-red-500'}`}
                            data-testid="name-input"
                            id="comments-name"
                            maxLength={64}
                            name="name"
                            placeholder={t('Jamie Larson')}
                            type="text"
                            value={name}
                            onChange={(e) => {
                                setName(e.currentTarget.value);
                            }}
                            onKeyDown={(e) => {
                                if (e.key === 'Enter') {
                                    setName(e.currentTarget.value);
                                    // eslint-disable-next-line no-console
                                    submit().catch(console.error);
                                }
                            }}
                        />
                        <div className="mb-2 mt-6 flex flex-row justify-between">
                            <label className="font-sans text-[1.3rem] font-semibold" htmlFor="comments-name">{t('Expertise')}</label>
                            <div className={`font-sans text-[1.3rem] text-neutral-400 ${(expertiseCharsLeft === 0) && 'text-red-500'}`}>{charsText}</div>
                        </div>
                        <input
                            ref={inputExpertiseRef}
                            className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${(expertiseCharsLeft === 0) && 'border-red-500 focus:border-red-500'}`}
                            data-testid="expertise-input"
                            id="comments-expertise"
                            maxLength={maxExpertiseChars}
                            name="expertise"
                            placeholder={t('Head of Marketing at Acme, Inc')}
                            type="text"
                            value={expertise}
                            onChange={(e) => {
                                const expertiseText = e.currentTarget.value;
                                setExpertiseCharsLeft(maxExpertiseChars - expertiseText.length);
                                setExpertise(expertiseText);
                            }}
                            onKeyDown={(e) => {
                                if (e.key === 'Enter') {
                                    setExpertise(e.currentTarget.value);
                                    // eslint-disable-next-line no-console
                                    submit().catch(console.error);
                                }
                            }}
                        />
                        <button
                            className={`mt-10 flex h-[42px] w-full items-center justify-center rounded-md px-8 font-sans text-[15px] font-semibold text-white opacity-100 transition-opacity duration-200 ease-linear hover:opacity-90`}
                            data-testid="save-button"
                            style={{backgroundColor: accentColor ?? '#000000'}}
                            type="button"
                            onClick={() => {
                                // eslint-disable-next-line no-console
                                submit().catch(console.error);
                            }}
                        >
                            {t('Save')}
                        </button>
                    </section>
                </div>
                <CloseButton close={() => close(false)} />
            </div>
        </div>
    );
};

export default AddDetailsPopup;