client/components/mma/switch/review/SwitchReview.tsx (313 lines of code) (raw):
import { css } from '@emotion/react';
import {
from,
palette,
space,
textSans17,
textSansBold17,
} from '@guardian/source/foundations';
import {
Button,
Stack,
SvgClock,
SvgCreditCard,
themeButtonReaderRevenueBrand,
} from '@guardian/source/react-components';
import { useContext, useState } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router';
import { SwitchErrorSummary } from '@/client/components/shared/productSwitch/SwitchErrorSummary';
import { MDA_TEST_USER_HEADER } from '@/shared/productResponse';
import { dateString } from '../../../../../shared/dates';
import type {
PreviewResponse,
ProductSwitchType,
} from '../../../../../shared/productSwitchTypes';
import {
buttonCentredCss,
buttonMutedCss,
} from '../../../../styles/ButtonStyles';
import {
iconListCss,
listWithDividersCss,
productTitleCss,
sectionSpacing,
} from '../../../../styles/GenericStyles';
import {
LoadingState,
useAsyncLoader,
} from '../../../../utilities/hooks/useAsyncLoader';
import { formatAmount } from '../../../../utilities/utils';
import { GenericErrorScreen } from '../../../shared/GenericErrorScreen';
import { SwitchPaymentInfo } from '../../../shared/productSwitch/SwitchPaymentInfo';
import { SwitchOffsetPaymentIcon } from '../../shared/assets/SwitchOffsetPaymentIcon';
import { JsonResponseHandler } from '../../shared/asyncComponents/DefaultApiResponseHandler';
import { DefaultLoadingView } from '../../shared/asyncComponents/DefaultLoadingView';
import { BenefitsToggle } from '../../shared/benefits/BenefitsToggle';
import { Card } from '../../shared/Card';
import { Heading } from '../../shared/Heading';
import { PaymentDetails } from '../../shared/PaymentDetails';
import { SupporterPlusTsAndCs } from '../../shared/SupporterPlusTsAndCs';
import type {
SwitchContextInterface,
SwitchRouterState,
} from '../SwitchContainer';
import { SwitchContext } from '../SwitchContainer';
const newAmountCss = css`
${textSansBold17};
padding-top: ${space[3]}px;
margin-top: ${space[4]}px;
margin-bottom: 0;
border-top: 1px solid ${palette.neutral[86]};
`;
const buttonLayoutCss = css`
display: flex;
flex-direction: column;
margin-top: ${space[5]}px;
padding-top: 32px;
border-top: 1px solid ${palette.neutral[86]};
> * + * {
margin-top: ${space[3]}px;
}
${from.tablet} {
flex-direction: row;
> * + * {
margin-top: 0;
margin-left: ${space[3]}px;
}
}
`;
const scrollToErrorMessage = () => {
const errorMessageElement = document.getElementById(
'productSwitchErrorMessage',
);
errorMessageElement?.scrollIntoView();
};
const productSwitchType: ProductSwitchType =
'recurring-contribution-to-supporter-plus';
export const SwitchReview = () => {
const navigate = useNavigate();
const location = useLocation();
const routerState = location.state as SwitchRouterState;
const [isSwitching, setIsSwitching] = useState<boolean>(false);
const [switchingError, setSwitchingError] = useState<boolean>(false);
const switchContext = useContext(SwitchContext) as SwitchContextInterface;
const {
contributionToSwitch,
mainPlan,
monthlyOrAnnual,
supporterPlusTitle,
thresholds,
} = switchContext;
const inPaymentFailure = !!contributionToSwitch.alertText;
const { thresholdForBillingPeriod: threshold, isAboveThreshold } =
thresholds;
const newAmount = Math.max(threshold, mainPlan.price / 100);
const productMoveFetch = (
preview: boolean,
checkChargeAmountBeforeUpdate: boolean,
) =>
fetch(
`/api/product-move/${productSwitchType}/${contributionToSwitch.subscription.subscriptionId}`,
{
method: 'POST',
body: JSON.stringify({
price: newAmount,
preview,
checkChargeAmountBeforeUpdate,
}),
headers: {
'Content-Type': 'application/json',
[MDA_TEST_USER_HEADER]: `${contributionToSwitch.isTestUser}`,
},
},
);
const confirmSwitch = async (
amount: number,
checkChargeAmountBeforeUpdate: boolean,
) => {
if (isSwitching) {
return;
}
if (inPaymentFailure) {
setSwitchingError(true);
scrollToErrorMessage();
return;
}
try {
setIsSwitching(true);
const response = await productMoveFetch(
false,
checkChargeAmountBeforeUpdate,
);
const data = await JsonResponseHandler(response);
if (data === null) {
setIsSwitching(false);
setSwitchingError(true);
scrollToErrorMessage();
} else {
navigate('../complete', {
state: {
...routerState,
amountPayableToday: amount,
nextPaymentDate: nextPaymentDate,
switchHasCompleted: true,
},
});
}
} catch {
setIsSwitching(false);
setSwitchingError(true);
scrollToErrorMessage();
}
};
const {
data: previewResponse,
loadingState,
}: {
data: PreviewResponse | null;
loadingState: LoadingState;
} = useAsyncLoader(
() => productMoveFetch(true, false),
JsonResponseHandler,
);
if (loadingState == LoadingState.HasError) {
return <GenericErrorScreen />;
}
if (loadingState == LoadingState.IsLoading) {
return <DefaultLoadingView />;
}
if (previewResponse === null) {
return <Navigate to="/" />;
}
const nextPaymentDate = dateString(
new Date(previewResponse.nextPaymentDate),
'd MMMM',
);
return (
<>
<section css={sectionSpacing}>
<Stack space={3}>
<Heading sansSerif>Review change</Heading>
<p
css={css`
${textSans17};
`}
>
{isAboveThreshold
? `Please confirm your choice to get exclusive supporter extras. You'll still pay ${
mainPlan.currency
}${formatAmount(newAmount)} per ${
mainPlan.billingPeriod
}.`
: `Please confirm your choice to change your support to ${monthlyOrAnnual.toLowerCase()} + extras.`}
</p>
</Stack>
</section>
<section css={sectionSpacing}>
<Stack space={3}>
<Heading sansSerif>Your new support</Heading>
<Card>
<Card.Header backgroundColor={palette.brand[500]}>
<h3 css={productTitleCss}>{supporterPlusTitle}</h3>
</Card.Header>
<Card.Section>
<p
css={css`
${textSans17};
margin: 0;
max-width: 40ch;
`}
>
{monthlyOrAnnual} support with exclusive extras,
including full access to our app and ad-free
reading
</p>
<BenefitsToggle
productType="supporterplus"
subscriptionPlan={mainPlan}
/>
<p css={newAmountCss}>
{mainPlan.currency}
{formatAmount(newAmount)}/
{mainPlan.billingPeriod}
</p>
</Card.Section>
</Card>
</Stack>
</section>
<section css={sectionSpacing}>
<Stack space={4}>
<Heading sansSerif>What happens next?</Heading>
<ul css={[iconListCss, listWithDividersCss]}>
<li>
<SvgClock size="medium" />
<span>
<strong>This change will happen today</strong>
<br />
In just a couple of steps, you'll be able to
start enjoying your exclusive extras{' '}
</span>
</li>
<li
css={css`
color: ${palette.success[400]};
`}
>
<SwitchOffsetPaymentIcon size="medium" />
<span>
<SwitchPaymentInfo
amountPayableToday={
previewResponse.amountPayableToday
}
alreadyPayingAboveThreshold={
isAboveThreshold
}
currencySymbol={mainPlan.currency}
supporterPlusPurchaseAmount={
previewResponse.supporterPlusPurchaseAmount
}
billingPeriod={mainPlan.billingPeriod}
nextPaymentDate={nextPaymentDate}
/>
</span>
</li>
<li>
<SvgCreditCard size="medium" />
<span>
<strong>Your payment method</strong>
<br />
We will take payment as before, from{' '}
<PaymentDetails
subscription={
contributionToSwitch.subscription
}
/>
</span>
</li>
</ul>
</Stack>
</section>
<section css={buttonLayoutCss}>
<Button
theme={themeButtonReaderRevenueBrand}
isLoading={isSwitching}
cssOverrides={buttonCentredCss}
onClick={() =>
confirmSwitch(
previewResponse.amountPayableToday,
previewResponse.checkChargeAmountBeforeUpdate,
)
}
>
Confirm {isAboveThreshold ? 'change' : 'upgrade'}
</Button>
<Button
priority="tertiary"
cssOverrides={[buttonCentredCss, buttonMutedCss]}
onClick={() => navigate('..', { state: routerState })}
>
Back
</Button>
</section>
{switchingError && (
<section css={sectionSpacing} id="productSwitchErrorMessage">
<SwitchErrorSummary inPaymentFailure={inPaymentFailure} />
</section>
)}
<section css={sectionSpacing}>
<SupporterPlusTsAndCs
currencyISO={mainPlan.currencyISO}
billingPeriod={mainPlan.billingPeriod}
/>
</section>
</>
);
};