client/components/mma/cancel/cancellationSaves/CancelAlternativeReview.tsx (364 lines of code) (raw):
import { css } from '@emotion/react';
import {
from,
neutral,
palette,
space,
textEgyptian17,
textEgyptianBold17,
textSans12,
textSans17,
textSansBold17,
} from '@guardian/source/foundations';
import { Button, Spinner } from '@guardian/source/react-components';
import { ErrorSummary } from '@guardian/source-development-kitchen/react-components';
import { capitalize } from 'lodash';
import type { ReactElement } from 'react';
import { useContext, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Ribbon } from '@/client/components/shared/Ribbon';
import { measure } from '@/client/styles/typography';
import type { DiscountPreviewResponse } from '@/client/utilities/discountPreview';
import { getMaxNonDiscountedPrice } from '@/client/utilities/discountPreview';
import { fetchWithDefaultParameters } from '@/client/utilities/fetch';
import { DATE_FNS_LONG_OUTPUT_FORMAT, parseDate } from '@/shared/dates';
import { number2words } from '@/shared/numberUtils';
import { getMainPlan, isPaidSubscriptionPlan } from '@/shared/productResponse';
import type { DeliveryRecordDetail } from '../../delivery/records/deliveryRecordsApi';
import type { OutstandingHolidayStop } from '../../holiday/HolidayStopApi';
import { Heading } from '../../shared/Heading';
import { ProgressStepper } from '../../shared/ProgressStepper';
import type { CancellationContextInterface } from '../CancellationContainer';
import { CancellationContext } from '../CancellationContainer';
import type { OptionalCancellationReasonId } from '../cancellationReason';
interface RouterSate extends DiscountPreviewResponse {
selectedReasonId: OptionalCancellationReasonId;
cancellationPolicy: string;
caseId: string;
holidayStops?: OutstandingHolidayStop[];
deliveryCredits?: DeliveryRecordDetail[];
}
type OfferApiCallStatus = 'NOT_READY' | 'PENDING' | 'FAILED' | 'SUCCESS';
const yourOfferBoxCss = css`
background-color: #fbf6ef;
padding: ${space[4]}px ${space[6]}px;
position: relative;
h4 {
${textSansBold17};
margin: 0;
}
p {
margin: 0;
}
`;
const ribbonCss = css`
position: absolute;
top: 0;
left: ${space[3]}px;
transform: translateY(-50%);
`;
const yourOfferBoxFlexCss = css`
display: flex;
flex-direction: column;
${from.desktop} {
flex-direction: row;
gap: 1ch;
}
`;
const strikethroughPriceCss = css`
${textSans17};
color: ${neutral[46]};
margin: 0;
`;
const percentageOfferSubText = css`
${textSans12};
color: ${neutral[38]};
margin-top: ${space[2]}px;
`;
const whatsNextTitleCss = css`
${textEgyptianBold17};
margin-top: ${space[6]}px;
${from.desktop} {
margin-top: ${space[8]}px;
}
`;
const whatsNextListCss = css`
${textEgyptian17};
padding: 0;
padding-inline-start: 14px;
li + li {
margin-top: ${space[3]}px;
}
`;
const buttonsCtaHolder = css`
margin: ${space[8]}px 0 ${space[6]}px;
display: flex;
flex-direction: column;
gap: ${space[2]}px;
${from.phablet} {
flex-direction: row;
gap: ${space[6]}px;
margin-top: ${space[9]}px;
}
`;
const ctaBtnCss = css`
width: 100%;
justify-content: center;
${from.desktop} {
width: fit-content;
}
`;
const termsCss = css`
${textSans12};
color: ${palette.neutral[46]};
margin-top: ${space[3]}px;
`;
export const CancelAlternativeReview = () => {
const location = useLocation();
const routerState = location.state as RouterSate;
const navigate = useNavigate();
const cancellationContext = useContext(
CancellationContext,
) as CancellationContextInterface;
const productDetail = cancellationContext.productDetail;
const productType = cancellationContext.productType;
const mainPlan = getMainPlan(productDetail.subscription);
const offerPeriodWord = number2words(routerState.upToPeriods);
const offerPeriodType = routerState.upToPeriodsType;
const firstDiscountedPaymentDate = parseDate(
routerState.firstDiscountedPaymentDate,
'yyyy-MM-dd',
).dateStr(DATE_FNS_LONG_OUTPUT_FORMAT);
const nextNonDiscountedPaymentDate = parseDate(
routerState.nextNonDiscountedPaymentDate,
'yyyy-MM-dd',
).dateStr(DATE_FNS_LONG_OUTPUT_FORMAT);
const humanReadableStrikethroughPrice = getMaxNonDiscountedPrice(
routerState.nonDiscountedPayments,
true,
);
const [performingDiscountStatus, setPerformingDiscountStatus] =
useState<OfferApiCallStatus>('NOT_READY');
const confirmBtnIconProps: {
icon?: ReactElement;
iconSide?: 'right';
disabled?: true;
['aria-disabled']?: true;
} = {};
if (performingDiscountStatus === 'PENDING') {
confirmBtnIconProps.icon = <Spinner size="xsmall" />;
confirmBtnIconProps.iconSide = 'right';
confirmBtnIconProps.disabled = true;
confirmBtnIconProps['aria-disabled'] = true;
}
const alternativeIsOffer = productType.productType === 'supporterplus';
const alternativeIsPause = productType.productType === 'contributions';
const offerIsPercentageOrFree: 'percentage' | 'free' | false =
alternativeIsOffer &&
(routerState.discountPercentage < 100 ? 'percentage' : 'free');
const handleConfirmClick = async () => {
setPerformingDiscountStatus('PENDING');
try {
const response = await fetchWithDefaultParameters(
'/api/discounts/apply-discount',
{
method: 'POST',
body: JSON.stringify({
subscriptionNumber:
productDetail.subscription.subscriptionId,
}),
},
);
if (response.ok) {
const confirmedUrlPart = `../${
(alternativeIsOffer && 'offer-confirmed') || ''
}${(alternativeIsPause && 'pause-confirmed') || ''}`;
navigate(confirmedUrlPart, {
state: routerState,
});
} else {
setPerformingDiscountStatus('FAILED');
}
} catch {
setPerformingDiscountStatus('FAILED');
}
};
return (
<>
<ProgressStepper
steps={[{}, {}, {}, { isCurrentStep: true }]}
additionalCSS={css`
margin: ${space[8]}px 0 ${space[9]}px;
`}
/>
<Heading
borderless
cssOverrides={[
measure.heading,
css`
margin-bottom: ${space[6]}px;
`,
]}
>
{alternativeIsOffer && 'Your offer'}
{alternativeIsPause && "Let's confirm the details"}
</Heading>
<div
css={[
yourOfferBoxCss,
offerIsPercentageOrFree === 'percentage' &&
css`
padding-top: ${space[6]}px;
`,
]}
>
{offerIsPercentageOrFree === 'percentage' && (
<Ribbon
copy={`${routerState.discountPercentage}% off`}
ribbonColour={palette.news[400]}
copyColour={palette.neutral[100]}
roundedCornersLeft
roundedCornersRight
withoutTail
small
additionalCss={ribbonCss}
/>
)}
<div
css={[
yourOfferBoxFlexCss,
offerIsPercentageOrFree === 'percentage' &&
css`
gap: 1ch;
flex-direction: row;
`,
]}
>
{alternativeIsOffer && isPaidSubscriptionPlan(mainPlan) && (
<p css={strikethroughPriceCss}>
<s>
{mainPlan.currency}
{humanReadableStrikethroughPrice}/
{mainPlan.billingPeriod}
</s>
</p>
)}
<h4>
{alternativeIsOffer &&
offerIsPercentageOrFree === 'free' &&
`${capitalize(
offerPeriodWord,
)} ${offerPeriodType} of free access to your digital subscription`}
{alternativeIsOffer &&
isPaidSubscriptionPlan(mainPlan) &&
offerIsPercentageOrFree === 'percentage' && (
<>
{mainPlan.currency}
{routerState.discountedPrice}/
{mainPlan.billingPeriod}
</>
)}
{alternativeIsPause &&
`You'd like to pause your recurring support for ${offerPeriodWord} ${offerPeriodType}`}
</h4>
</div>
{alternativeIsOffer &&
isPaidSubscriptionPlan(mainPlan) &&
offerIsPercentageOrFree === 'percentage' && (
<p>For your {capitalize(productType.friendlyName)}</p>
)}
</div>
{offerIsPercentageOrFree === 'percentage' &&
isPaidSubscriptionPlan(mainPlan) && (
<p css={percentageOfferSubText}>
You will pay {mainPlan.currency}
{routerState.discountedPrice} for the next{' '}
{routerState.upToPeriods} {offerPeriodType} then{' '}
{mainPlan.currency}
{getMaxNonDiscountedPrice(
routerState.nonDiscountedPayments,
true,
)}
/{mainPlan.billingPeriod}
</p>
)}
<h3 css={whatsNextTitleCss}>
{alternativeIsOffer && 'If you choose to stay with us:'}
{alternativeIsPause && 'This means:'}
</h3>
<ul css={whatsNextListCss}>
{alternativeIsOffer && (
<>
<li>
{offerIsPercentageOrFree === 'free' &&
`Your ${offerPeriodWord} ${offerPeriodType} of free access will begin on ${firstDiscountedPaymentDate} (when your next payment would usually be due)`}
{offerIsPercentageOrFree === 'percentage' &&
isPaidSubscriptionPlan(mainPlan) &&
`You will benefit from the discounted rate, and will be charged ${mainPlan.currency}${routerState.discountedPrice} on your next payment date`}
</li>
<li>
{offerIsPercentageOrFree === 'free' &&
`Unless you cancel before, your payment will resume on ${nextNonDiscountedPaymentDate}`}
{offerIsPercentageOrFree === 'percentage' &&
isPaidSubscriptionPlan(mainPlan) &&
`Unless you cancel before then, your ${
mainPlan.currency
}${getMaxNonDiscountedPrice(
routerState.nonDiscountedPayments,
true,
)}/${
mainPlan.billingPeriod
} payment will automatically resume on ${nextNonDiscountedPaymentDate}`}
</li>
<li>You may cancel your subscription at any time</li>
</>
)}
{alternativeIsPause && (
<>
<li>
Unless you cancel before, your monthly support will
resume on {nextNonDiscountedPaymentDate}
</li>
<li>
You'll continue to receive our monthly support
newsletter (unless you've opted out)
</li>
<li>You may return to cancel at any time.</li>
</>
)}
</ul>
{performingDiscountStatus === 'FAILED' && (
<ErrorSummary
cssOverrides={css`
margin-top: ${space[9]}px;
`}
message="Unable to complete request"
context={
<>
We're sorry, but we couldn't complete your request
at this time.
<br />
Please try again later. If the problem persists,
contact our support team for assistance.
<br />
<Link to="/">Return to your account</Link>
</>
}
/>
)}
<div css={buttonsCtaHolder}>
<Button
cssOverrides={ctaBtnCss}
{...confirmBtnIconProps}
onClick={handleConfirmClick}
>
{alternativeIsOffer ? 'Confirm your offer' : ''}
{alternativeIsPause ? 'Confirm pausing your support' : ''}
</Button>
<Button
priority="subdued"
cssOverrides={ctaBtnCss}
onClick={() => {
const backUrl = `../${
alternativeIsOffer ? 'offer' : ''
}${alternativeIsPause ? 'pause' : ''}`;
navigate(backUrl, { state: routerState });
// we need to explicitly pass the state here to
// avoid a render in the previous page where the
// state is not yet available
}}
>
Go back
</Button>
</div>
{isPaidSubscriptionPlan(mainPlan) && (
<>
{alternativeIsOffer &&
offerIsPercentageOrFree === 'free' && (
<p css={termsCss}>
If you cancel during the free period, you will
lose access to your benefits on the day we
usually take payment. If you cancel after the
free period, your subscription will end at the
end of your current {mainPlan.billingPeriod}ly
payment period.
</p>
)}
{alternativeIsOffer &&
offerIsPercentageOrFree === 'percentage' && (
<p css={termsCss}>
If you take up the{' '}
{routerState.discountPercentage}% off offer and
cancel during that {mainPlan.billingPeriod}, you
will lose access to your benefits at the end of
the {mainPlan.billingPeriod}. If you cancel
after the offer, when your {mainPlan.currency}
{getMaxNonDiscountedPrice(
routerState.nonDiscountedPayments,
true,
)}
/{mainPlan.billingPeriod} payment automatically
resumes, your subscription will end at the end
of your current {mainPlan.billingPeriod}ly
payment period
</p>
)}
{alternativeIsPause && (
<p css={termsCss}>
If you cancel during the paused period, your{' '}
{mainPlan.billingPeriod}ly payments will not
automatically resume. If you cancel after the paused
period, cancellation will take effect immediately
and you will not be charged again.
</p>
)}
</>
)}
</>
);
};