client/components/mma/accountoverview/updateAmount/ContributionUpdateAmountForm.tsx (377 lines of code) (raw):

import { css } from '@emotion/react'; import { from, palette, space, textSans17 } from '@guardian/source/foundations'; import { Button, ChoiceCard, ChoiceCardGroup, InlineError, LinkButton, TextInput, } from '@guardian/source/react-components'; import { capitalize } from 'lodash'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import type { PaidSubscriptionPlan } from '../../../../../shared/productResponse'; import { augmentBillingPeriod } from '../../../../../shared/productResponse'; import type { ProductType } from '../../../../../shared/productTypes'; import { trackEvent } from '../../../../utilities/analytics'; import { fetchWithDefaultParameters } from '../../../../utilities/fetch'; import type { ContributionInterval } from '../../../../utilities/pricingConfig/contributionsAmount'; import { contributionAmountsLookup } from '../../../../utilities/pricingConfig/contributionsAmount'; import { TextResponseHandler } from '../../shared/asyncComponents/DefaultApiResponseHandler'; import { DefaultLoadingView } from '../../shared/asyncComponents/DefaultLoadingView'; type ContributionUpdateAmountFormMode = 'MANAGE' | 'CANCELLATION_SAVE'; interface ContributionUpdateAmountFormProps { subscriptionId: string; mainPlan: PaidSubscriptionPlan; productType: ProductType; // we use this over the value in mainPlan as that value isn't updated after the user submits this form currentAmount: number; nextPaymentDate: string | null; mode: ContributionUpdateAmountFormMode; onUpdateConfirmed: (updatedAmount: number) => void; withReturnToAccountOverviewButton?: true; } const buttonsCss = css` display: flex; flex-direction: column; gap: ${space[5]}px; ${from.tablet} { flex-direction: row; } `; const buttonCss = css` justify-content: center; `; const getAmountUpdater = ( newAmount: number, productType: ProductType, subscriptionName: string, ) => fetchWithDefaultParameters( `/api/update/amount/${productType.urlPart}/${subscriptionName}`, { method: 'POST', body: JSON.stringify({ newPaymentAmount: newAmount }), }, ); function weeklyBreakDown( chosenAmount: number | null, billingPeriod: string, currencySymbol: string, ): string | null { if (!chosenAmount) { return null; } let weeklyAmount: number; if (billingPeriod === 'month') { weeklyAmount = (chosenAmount * 12) / 52; } else { weeklyAmount = chosenAmount / 52; } return `Contributing ${currencySymbol}${chosenAmount} works out as ${currencySymbol}${weeklyAmount.toFixed( 2, )} each week`; } function validateChoice( currentAmount: number, chosenAmount: number | null, minAmount: number, maxAmount: number, isOtherAmountSelected: boolean, mainPlan: PaidSubscriptionPlan, ): string | null { const chosenOptionNum = Number(chosenAmount); if (!chosenAmount && !isOtherAmountSelected) { return 'Please make a selection'; } else if (chosenOptionNum === currentAmount) { return 'You have selected the same amount as you currently contribute'; } else if (!chosenAmount || isNaN(chosenOptionNum)) { return 'There is a problem with the amount you have selected, please make sure it is a valid amount'; } else if (!isNaN(chosenOptionNum) && chosenOptionNum < minAmount) { return `There is a minimum ${ mainPlan.billingPeriod }ly contribution amount of ${mainPlan.currency}${minAmount.toFixed( 2, )} ${mainPlan.currencyISO}`; } else if (!isNaN(chosenOptionNum) && chosenOptionNum > maxAmount) { return `There is a maximum ${ mainPlan.billingPeriod }ly contribution amount of ${mainPlan.currency}${maxAmount.toFixed( 2, )} ${mainPlan.currencyISO}`; } return null; } export const ContributionUpdateAmountForm = ( props: ContributionUpdateAmountFormProps, ) => { const currentContributionOptions = (contributionAmountsLookup[ props.mainPlan.currencyISO ] || contributionAmountsLookup.international)[ props.mainPlan.billingPeriod as ContributionInterval ]; const defaultOtherAmount = props.mode === 'MANAGE' ? currentContributionOptions.otherDefaultAmount : null; const defaultIsOtherAmountSelected = props.mode === 'CANCELLATION_SAVE'; const [otherAmount, setOtherAmount] = useState<number | null>( defaultOtherAmount, ); const [isOtherAmountSelected, setIsOtherAmountSelected] = useState<boolean>( defaultIsOtherAmountSelected, ); const [hasInteractedWithOtherAmount, setHasInteractedWithOtherAmount] = useState<boolean>(false); const [selectedValue, setSelectedValue] = useState<number | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null); const [hasSubmitted, setHasSubmitted] = useState<boolean>(false); const [showUpdateLoader, setShowUpdateLoader] = useState<boolean>(false); const [updateFailed, setUpdateFailedStatus] = useState<boolean>(false); const [confirmedAmount, setConfirmedAmount] = useState<number | null>(null); const chosenAmount = isOtherAmountSelected ? otherAmount : selectedValue; const navigate = useNavigate(); const onReturnClicked = (event: React.MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); navigate('/'); }; useEffect(() => { if (otherAmount !== defaultOtherAmount) { setHasInteractedWithOtherAmount(true); } }, [otherAmount, defaultOtherAmount]); useEffect(() => { const newErrorMessage = validateChoice( props.currentAmount, chosenAmount, currentContributionOptions.minAmount, currentContributionOptions.maxAmount, isOtherAmountSelected, props.mainPlan, ); setErrorMessage(newErrorMessage); }, [ otherAmount, selectedValue, chosenAmount, isOtherAmountSelected, currentContributionOptions.minAmount, currentContributionOptions.maxAmount, props.currentAmount, props.mainPlan, ]); useEffect(() => { if (confirmedAmount) { props.onUpdateConfirmed(confirmedAmount); } }, [confirmedAmount, props]); const changeAmountClick = async () => { setHasSubmitted(true); const newErrorMessage = validateChoice( props.currentAmount, chosenAmount, currentContributionOptions.minAmount, currentContributionOptions.maxAmount, isOtherAmountSelected, props.mainPlan, ); if (newErrorMessage) { setErrorMessage(newErrorMessage); return; } setShowUpdateLoader(true); const response = await getAmountUpdater( pendingAmount, props.productType, props.subscriptionId, ); const data = await TextResponseHandler(response); if (data === null) { trackEvent({ eventCategory: 'amount_change', eventAction: 'contributions_amount_change_failed', }); setUpdateFailedStatus(true); setShowUpdateLoader(false); } trackEvent({ eventCategory: 'amount_change', eventAction: 'contributions_amount_change_success', eventLabel: `by ${props.mainPlan.currency}${( pendingAmount - props.currentAmount ).toFixed(2)}${props.mainPlan.currencyISO}`, }); setConfirmedAmount(pendingAmount); }; const pendingAmount = Number( isOtherAmountSelected ? otherAmount : selectedValue, ); const amountLabel = (amount: number) => { return `${props.mainPlan.currency} ${amount} per ${props.mainPlan.billingPeriod}`; }; const shouldShowChoices = props.mode === 'MANAGE'; const shouldShowSelectedAmountErrorMessage = !isOtherAmountSelected && (selectedValue || hasSubmitted); const shouldShowOtherAmountErrorMessage = hasInteractedWithOtherAmount || hasSubmitted; const otherAmountLabel = props.mode === 'MANAGE' ? `Other amount (${props.mainPlan.currency})` : `Amount (${props.mainPlan.currency})`; if (showUpdateLoader) { return <DefaultLoadingView loadingMessage="Updating..." />; } return ( <> {updateFailed && ( <InlineError> Updating failed this time. Please try again later... </InlineError> )} <div css={css` border: 1px solid ${palette.neutral[20]}; margin-bottom: ${space[5]}px; `} > <dl css={css` padding: ${space[3]}px ${space[5]}px; margin: 0; border-bottom: 1px solid ${palette.neutral[20]}; ${textSans17}; `} > <dt css={css` font-weight: bold; display: inline-block; `} > {capitalize( augmentBillingPeriod(props.mainPlan.billingPeriod), )}{' '} amount </dt> <dd css={css` display: inline-block; `} >{`${props.mainPlan.currency}${props.currentAmount.toFixed( 2, )} ${props.mainPlan.currencyISO}`}</dd> </dl> <div css={css` ${textSans17}; padding: ${space[3]}px ${space[5]}px; `} > {shouldShowSelectedAmountErrorMessage && errorMessage && ( <InlineError>{errorMessage}</InlineError> )} <div css={css` max-width: 500px; `} > {shouldShowChoices && ( <ChoiceCardGroup name="amounts" data-cy="contribution-amount-choices" label="Choose the amount to contribute" columns={2} > <> {currentContributionOptions.amounts.map( (amount) => ( <ChoiceCard id={`amount-${amount}`} key={amount} value={amount.toString()} label={amountLabel(amount)} checked={ selectedValue === amount } onChange={() => { setSelectedValue(amount); setIsOtherAmountSelected( false, ); }} /> ), )} <ChoiceCard id={`amount-other`} value="Other" label="Other" checked={isOtherAmountSelected} onChange={() => { setIsOtherAmountSelected(true); setSelectedValue(null); }} /> </> </ChoiceCardGroup> )} {isOtherAmountSelected && ( <div css={css` margin-top: ${space[3]}px; `} > <TextInput label={otherAmountLabel} supporting={`Sorry, we are only able to accept contributions of ${props.mainPlan.currency}${currentContributionOptions.minAmount} or over due to transaction fees`} error={ (shouldShowOtherAmountErrorMessage && errorMessage) || undefined } type="number" value={otherAmount?.toString() || ''} onChange={(event) => setOtherAmount( event.target.value ? Number(event.target.value) : null, ) } /> </div> )} </div> <div css={css` margin-top: ${space[2]}px; color: ${palette.neutral[46]}; font-size: 15px; `} > <em> {weeklyBreakDown( chosenAmount, props.mainPlan.billingPeriod, props.mainPlan.currency, )} </em> </div> </div> </div> <div css={buttonsCss}> <Button onClick={changeAmountClick} cssOverrides={buttonCss}> Change amount </Button> {props.withReturnToAccountOverviewButton && ( <LinkButton href="/" onClick={onReturnClicked} priority="subdued" cssOverrides={buttonCss} > Return to your account </LinkButton> )} </div> </> ); };