client/components/mma/accountoverview/updateAmount/SupporterPlusUpdateAmountForm.tsx (360 lines of code) (raw):
import { css } from '@emotion/react';
import {
palette,
space,
textSans14,
textSans17,
until,
} from '@guardian/source/foundations';
import {
Button,
ChoiceCard,
ChoiceCardGroup,
InlineError,
Link,
SvgInfoRound,
TextInput,
themeButtonReaderRevenueBrand,
} from '@guardian/source/react-components';
import { useEffect, useState } from 'react';
import type { PaidSubscriptionPlan } from '../../../../../shared/productResponse';
import { MDA_TEST_USER_HEADER } from '../../../../../shared/productResponse';
import { getBillingPeriodAdjective } from '../../../../../shared/productTypes';
import { fetchWithDefaultParameters } from '../../../../utilities/fetch';
import { getSupporterPlusSuggestedAmountsFromMainPlan } from '../../../../utilities/pricingConfig/suggestedAmounts';
import { supporterPlusPriceConfigByCountryGroup } from '../../../../utilities/pricingConfig/supporterPlusPricing';
import { JsonResponseHandler } from '../../shared/asyncComponents/DefaultApiResponseHandler';
import { DefaultLoadingView } from '../../shared/asyncComponents/DefaultLoadingView';
const smallPrintCss = css`
${textSans14};
margin-top: 0;
margin-bottom: 0;
color: #606060;
> a {
color: inherit;
text-decoration: underline;
}
& + & {
margin-top: ${space[1]}px;
}
`;
const buttonContainerCss = css`
${until.tablet} {
display: flex;
flex-direction: column;
}
`;
const buttonCentredCss = css`
justify-content: center;
`;
const getAmountUpdater = (
newAmount: number,
subscriptionName: string,
isTestUser: boolean,
) =>
fetchWithDefaultParameters(
`/api/update-supporter-plus-amount/${subscriptionName}`,
{
method: 'POST',
body: JSON.stringify({ newPaymentAmount: newAmount }),
headers: {
[MDA_TEST_USER_HEADER]: `${isTestUser}`,
},
},
);
function validateChoice(
currentAmount: number,
chosenAmount: number | null,
minAmount: number,
maxAmount: number,
isOtherAmountSelected: boolean,
mainPlan: PaidSubscriptionPlan,
): string | null {
const chosenOptionNum = Number(chosenAmount);
const monthlyOrAnnual = getBillingPeriodAdjective(
mainPlan.billingPeriod,
).toLocaleLowerCase();
if (!chosenAmount && !isOtherAmountSelected) {
return 'Please make a selection';
} else if (chosenOptionNum === currentAmount) {
return 'You have selected the same amount as you currently pay';
} 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 `${mainPlan.currency}${minAmount} per ${
mainPlan.billingPeriod
} is the ${
currentAmount < minAmount ? 'new ' : ''
}minimum payment to receive this subscription. Please call our customer service team to lower your ${monthlyOrAnnual} amount below ${
mainPlan.currency
}${minAmount} via the Help Centre`;
} else if (!isNaN(chosenOptionNum) && chosenOptionNum > maxAmount) {
return `There is a maximum ${mainPlan.billingPeriod}ly amount of ${mainPlan.currency}${maxAmount} ${mainPlan.currencyISO}`;
}
return null;
}
interface SupporterPlusUpdateAmountFormProps {
subscriptionId: string;
mainPlan: PaidSubscriptionPlan;
// 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;
onUpdateConfirmed: (updatedAmount: number) => void;
isTestUser: boolean;
}
export const SupporterPlusUpdateAmountForm = (
props: SupporterPlusUpdateAmountFormProps,
) => {
const priceConfig = (supporterPlusPriceConfigByCountryGroup[
props.mainPlan.currencyISO
] || supporterPlusPriceConfigByCountryGroup.international)[
props.mainPlan.billingPeriod
];
const currentAmountIsBelowNewMin =
props.currentAmount < priceConfig.minAmount;
const minPriceDisplay = `${props.mainPlan.currency}${priceConfig.minAmount}`;
const monthlyOrAnnual = getBillingPeriodAdjective(
props.mainPlan.billingPeriod,
);
const defaultOtherAmount = priceConfig.minAmount;
const [otherAmount, setOtherAmount] = useState<number | null>(
defaultOtherAmount,
);
const [isOtherAmountSelected, setIsOtherAmountSelected] =
useState<boolean>(false);
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;
useEffect(() => {
if (otherAmount !== defaultOtherAmount) {
setHasInteractedWithOtherAmount(true);
}
}, [otherAmount, defaultOtherAmount]);
useEffect(() => {
const newErrorMessage = validateChoice(
props.currentAmount,
chosenAmount,
priceConfig.minAmount,
priceConfig.maxAmount,
isOtherAmountSelected,
props.mainPlan,
);
setErrorMessage(newErrorMessage);
}, [
otherAmount,
selectedValue,
chosenAmount,
isOtherAmountSelected,
priceConfig.minAmount,
priceConfig.maxAmount,
props.currentAmount,
props.mainPlan,
]);
useEffect(() => {
if (confirmedAmount) {
props.onUpdateConfirmed(confirmedAmount);
}
}, [confirmedAmount, props]);
const pendingAmount = Number(
isOtherAmountSelected ? otherAmount : selectedValue,
);
const amountLabel = (amount: number) => {
return `${props.mainPlan.currency}${amount} a ${props.mainPlan.billingPeriod}`;
};
const shouldShowSelectedAmountErrorMessage =
!isOtherAmountSelected && (selectedValue || hasSubmitted);
const shouldShowOtherAmountErrorMessage =
hasInteractedWithOtherAmount || hasSubmitted;
const otherAmountLabel = `Other amount (${props.mainPlan.currency})`;
const changeAmountClick = async () => {
setHasSubmitted(true);
const newErrorMessage = validateChoice(
props.currentAmount,
chosenAmount,
priceConfig.minAmount,
priceConfig.maxAmount,
isOtherAmountSelected,
props.mainPlan,
);
if (newErrorMessage) {
setErrorMessage(newErrorMessage);
return;
}
setShowUpdateLoader(true);
const response = await getAmountUpdater(
pendingAmount,
props.subscriptionId,
props.isTestUser,
);
try {
const data = await JsonResponseHandler(response);
if (data === null) {
setUpdateFailedStatus(true);
setShowUpdateLoader(false);
}
setConfirmedAmount(pendingAmount);
} catch {
setUpdateFailedStatus(true);
setShowUpdateLoader(false);
}
};
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;
`}
>
Current amount
</dt>
<dd
css={css`
margin-left: ${space[4]}px;
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;
`}
>
<ChoiceCardGroup
name="amounts"
data-cy="supporter-plus-amount-choices"
label="Choose your new amount"
columns={2}
>
<>
{getSupporterPlusSuggestedAmountsFromMainPlan(
props.mainPlan,
).map((amount, index) => (
<ChoiceCard
id={`amount-${amount}`}
key={`sp-amount-${amount}-index-${index}`}
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}
error={
(shouldShowOtherAmountErrorMessage &&
errorMessage) ||
undefined
}
type="number"
value={otherAmount?.toString() || ''}
onChange={(event) =>
setOtherAmount(
event.target.value
? Number(event.target.value)
: null,
)
}
/>
</div>
)}
<section
css={[
css`
margin-top: ${space[5]}px;
`,
buttonContainerCss,
]}
>
<Button
theme={themeButtonReaderRevenueBrand}
cssOverrides={buttonCentredCss}
onClick={changeAmountClick}
size="small"
>
Change amount
</Button>
</section>
<div
css={css`
margin-top: ${space[3]}px;
display: flex;
align-items: flex-start;
> svg {
flex-shrink: 0;
margin-right: 8px;
fill: ${palette.brand[500]};
}
`}
>
<SvgInfoRound
isAnnouncedByScreenReader
size="medium"
/>
<p>
If you would like to{' '}
{currentAmountIsBelowNewMin
? 'change'
: 'lower'}{' '}
your {monthlyOrAnnual.toLowerCase()} amount
below {minPriceDisplay} please call us via the{' '}
<Link href="/help-centre#call-us">
Help Centre
</Link>
</p>
</div>
<p css={smallPrintCss}>
{minPriceDisplay} per {props.mainPlan.billingPeriod}{' '}
is the {currentAmountIsBelowNewMin ? 'new ' : ''}
minimum payment to receive this subscription.
</p>
</div>
</div>
</div>
</>
);
};