client/components/mma/upgrade/ConfirmForm.tsx (366 lines of code) (raw):

import { css } from '@emotion/react'; import { from, palette, space, textSans15, textSans17, textSansBold17, textSansBold20, until, } from '@guardian/source/foundations'; import { Button, Stack, SvgClock, SvgCreditCard, SvgReload, themeButtonReaderRevenueBrand, } from '@guardian/source/react-components'; import { ToggleSwitch } from '@guardian/source-development-kitchen/react-components'; import type { Dispatch, SetStateAction } from 'react'; import { useContext, useState } from 'react'; import { useNavigate } from 'react-router'; import { formatAmount } from '@/client/utilities/utils'; import { dateString } from '../../../../shared/dates'; import type { BillingPeriod, PaidSubscriptionPlan, Subscription, } from '../../../../shared/productResponse'; import type { PreviewResponse } from '../../../../shared/productSwitchTypes'; import { buttonCentredCss, buttonContainerCss, } from '../../../styles/ButtonStyles'; import { iconListCss, listWithDividersCss, whatHappensNextIconCss, } from '../../../styles/GenericStyles'; import { fetchWithDefaultParameters } from '../../../utilities/fetch'; import { LoadingState } from '../../../utilities/hooks/useAsyncLoader'; import { calculateAmountPayableToday, calculateCheckChargeAmountBeforeUpdate, } from '../../../utilities/productMovePreview'; import { productMoveFetch } from '../../../utilities/productUtils'; import { GenericErrorScreen } from '../../shared/GenericErrorScreen'; import { SwitchErrorSummary } from '../../shared/productSwitch/SwitchErrorSummary'; import { SwitchPaymentInfo } from '../../shared/productSwitch/SwitchPaymentInfo'; import { JsonResponseHandler, TextResponseHandler, } from '../shared/asyncComponents/DefaultApiResponseHandler'; import { DefaultLoadingView } from '../shared/asyncComponents/DefaultLoadingView'; import { Heading } from '../shared/Heading'; import { PaymentDetails } from '../shared/PaymentDetails'; import { SupporterPlusTsAndCs } from '../shared/SupporterPlusTsAndCs'; import type { UpgradeRouterState, UpgradeSupportInterface, } from './UpgradeSupportContainer'; import { UpgradeSupportContext } from './UpgradeSupportContainer'; const WhatHappensNext = ({ amountPayableToday, mainPlan, subscription, nextPaymentDate, chosenAmount, alreadyPayingAboveThreshold, }: { amountPayableToday: number; mainPlan: PaidSubscriptionPlan; subscription: Subscription; nextPaymentDate: string; chosenAmount: number; alreadyPayingAboveThreshold: boolean; }) => { return ( <section css={css` border-bottom: 1px solid ${palette.neutral[86]}; padding-bottom: ${space[5]}px; `} > <Stack space={4}> <div css={css` border-top: 1px solid ${palette.neutral[86]}; padding-bottom: ${space[1]}px; `} > <h3 css={css` ${textSansBold20}; padding-top: ${space[1]}px; margin: 0; `} > What happens next? </h3> </div> <ul css={[ iconListCss, listWithDividersCss, whatHappensNextIconCss, ]} > <li> <SvgClock size="medium" /> <span> <strong css={css` padding-bottom: ${space[1]}px; `} > Price change will happen today </strong> <br /> You can start enjoying your exclusive extras straight away. </span> </li> <li> <SvgReload size="medium" /> <span> <SwitchPaymentInfo amountPayableToday={amountPayableToday} alreadyPayingAboveThreshold={ alreadyPayingAboveThreshold } currencySymbol={mainPlan.currency} supporterPlusPurchaseAmount={chosenAmount} billingPeriod={mainPlan.billingPeriod} nextPaymentDate={nextPaymentDate} /> </span> </li> <li> <SvgCreditCard size="medium" /> <span data-qm-masking="blocklist"> <strong css={css` padding-bottom: ${space[1]}px; `} > Your payment method </strong> <br /> The payment will be taken from{' '} <PaymentDetails subscription={subscription} /> </span> </li> </ul> </Stack> </section> ); }; const RoundUp = ({ setChosenAmount, thresholdAmount, chosenAmountPreRoundup, currencySymbol, billingPeriod, }: { setChosenAmount: Dispatch<SetStateAction<number | null>>; thresholdAmount: number; chosenAmountPreRoundup: number; currencySymbol: string; billingPeriod: BillingPeriod; }) => { const [hasRoundedUp, setHasRoundedUp] = useState<boolean>(false); return ( <section css={css` display: flex; justify-content: space-between; ${until.tablet} { padding: ${space[3]}px ${space[1]}px ${space[3]}px ${space[3]}px; } padding: ${space[3]}px ${space[2]}px ${space[3]}px ${space[4]}px; border-radius: 4px; border: 1px solid ${palette.neutral[86]}; background: ${hasRoundedUp ? palette.neutral[97] : palette.neutral[100]}; `} > <div> <div css={css` ${textSansBold17}; padding-right: ${space[4]}px; color: ${hasRoundedUp ? palette.neutral[0] : palette.neutral[20]}; `} > Round up to unlock extras ({currencySymbol} {thresholdAmount}/{billingPeriod}) </div> <div css={css` ${until.tablet} { ${textSans15}; } ${textSans17}; color: ${palette.neutral[46]}; `} > Get unlimited app access, ad-free reading, and more. </div> </div> <ToggleSwitch checked={hasRoundedUp} onClick={() => { const toggleRoundUp = !hasRoundedUp; setHasRoundedUp(toggleRoundUp); setChosenAmount( toggleRoundUp ? thresholdAmount : chosenAmountPreRoundup, ); }} /> </section> ); }; const updateContributionAmountFetch = ( newAmount: number, subscriptionId: string, ) => fetchWithDefaultParameters( `/api/update/amount/contributions/${subscriptionId}`, { method: 'POST', body: JSON.stringify({ newPaymentAmount: newAmount }), }, ); interface ConfirmFormProps { chosenAmount: number; setChosenAmount: Dispatch<SetStateAction<number | null>>; threshold: number; suggestedAmounts: number[]; previewResponse: PreviewResponse | null; previewLoadingState: LoadingState; } export const ConfirmForm = ({ chosenAmount, setChosenAmount, threshold, suggestedAmounts, previewResponse, previewLoadingState, }: ConfirmFormProps) => { const { mainPlan, subscription, inPaymentFailure, isTestUser } = useContext( UpgradeSupportContext, ) as UpgradeSupportInterface; const navigate = useNavigate(); const currencySymbol = mainPlan.currency; const aboveThreshold = chosenAmount >= threshold; const previousPrice = mainPlan.price / 100; const [shouldShowRoundUp] = useState<boolean>( !aboveThreshold && suggestedAmounts.includes(threshold), ); const [chosenAmountPreRoundup] = useState<number>(chosenAmount); const [isConfirmationLoading, setIsConfirmationLoading] = useState<boolean>(false); const [confirmationError, setConfirmationError] = useState<boolean>(false); if (previewLoadingState === LoadingState.IsLoading) { return ( <DefaultLoadingView loadingMessage="Loading your payment details..." /> ); } if ( previewLoadingState === LoadingState.HasError || previewResponse === null ) { return <GenericErrorScreen />; } const amountPayableToday = calculateAmountPayableToday( chosenAmount, previewResponse.contributionRefundAmount, ); const nextPaymentDate = dateString( new Date(previewResponse.nextPaymentDate), 'd MMMM', ); const checkChargeAmount = calculateCheckChargeAmountBeforeUpdate(amountPayableToday); const confirmOnClick = async () => { if (isConfirmationLoading) { return; } if (inPaymentFailure) { setConfirmationError(true); return; } setIsConfirmationLoading(true); const routerState = { chosenAmount, amountPayableToday, nextPaymentDate, journeyCompleted: true, } as UpgradeRouterState; try { if (aboveThreshold) { const data = await productMoveFetch( subscription.subscriptionId, chosenAmount, 'recurring-contribution-to-supporter-plus', checkChargeAmount, false, isTestUser, ).then((r) => JsonResponseHandler(r)); if (data === null) { setIsConfirmationLoading(false); setConfirmationError(true); } setIsConfirmationLoading(false); navigate('switch-thank-you', { state: routerState, }); } else { const data = await updateContributionAmountFetch( chosenAmount, subscription.subscriptionId, ).then((r) => TextResponseHandler(r)); if (data === null) { setIsConfirmationLoading(false); setConfirmationError(true); } setIsConfirmationLoading(false); navigate('thank-you', { state: routerState, }); } } catch { setIsConfirmationLoading(false); setConfirmationError(true); } }; const increaseText = chosenAmount > previousPrice ? 'increase' : 'change'; return ( <Stack space={4}> <section id="confirm-change" css={css` ${from.tablet} { padding-bottom: ${space[2]}px; } `} > <Heading sansSerif level="3" borderless> 2. Confirm support {increaseText} </Heading> <div css={css` ${textSans17}; `} > You've selected to support {currencySymbol} {formatAmount(chosenAmount)} per {mainPlan.billingPeriod} {aboveThreshold ? ', which unlocks all extras' : ''}. </div> </section> {shouldShowRoundUp && ( <RoundUp setChosenAmount={setChosenAmount} thresholdAmount={threshold} chosenAmountPreRoundup={chosenAmountPreRoundup} currencySymbol={currencySymbol} billingPeriod={mainPlan.billingPeriod} /> )} {aboveThreshold && ( <WhatHappensNext amountPayableToday={amountPayableToday} mainPlan={mainPlan} subscription={subscription} nextPaymentDate={nextPaymentDate} chosenAmount={chosenAmount} alreadyPayingAboveThreshold={previousPrice >= threshold} /> )} <section css={buttonContainerCss}> <Button theme={themeButtonReaderRevenueBrand} cssOverrides={buttonCentredCss} onClick={confirmOnClick} isLoading={isConfirmationLoading} > Confirm {increaseText} to {currencySymbol} {formatAmount(chosenAmount)}/{mainPlan.billingPeriod} </Button> </section> {confirmationError && ( <section id="upgradeSupportErrorMessage"> <SwitchErrorSummary inPaymentFailure={inPaymentFailure} /> </section> )} {aboveThreshold && ( <section> <SupporterPlusTsAndCs currencyISO={mainPlan.currencyISO} billingPeriod={mainPlan.billingPeriod} /> </section> )} </Stack> ); };