client/components/mma/cancel/cancellationSaves/CancelAlternativeOffer.tsx (477 lines of code) (raw):
import { css } from '@emotion/react';
import {
from,
neutral,
palette,
space,
textEgyptian17,
textSans12,
textSans17,
textSansBold15,
textSansBold20,
textSansBold24,
textSansBold28,
} from '@guardian/source/foundations';
import { Button } from '@guardian/source/react-components';
import { capitalize } from 'lodash';
import { useContext } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Pill } from '@/client/components/shared/Pill';
import { measure } from '@/client/styles/typography';
import type { DiscountPreviewResponse } from '@/client/utilities/discountPreview';
import { getMaxNonDiscountedPrice } from '@/client/utilities/discountPreview';
import {
DATE_FNS_LONG_OUTPUT_FORMAT,
getAppropriateReadableTimePeriod,
parseDate,
} from '@/shared/dates';
import { number2words } from '@/shared/numberUtils';
import { getMainPlan, isPaidSubscriptionPlan } from '@/shared/productResponse';
import type { ProductTypeKeys } from '@/shared/productTypes';
import type { DeliveryRecordDetail } from '../../delivery/records/deliveryRecordsApi';
import type { OutstandingHolidayStop } from '../../holiday/HolidayStopApi';
import { BenefitsSection } from '../../shared/benefits/BenefitsSection';
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[];
eligibleForFreePeriodOffer: boolean;
}
const standfirstCss = css`
${textEgyptian17};
color: ${neutral[7]};
margin: 0 0 ${space[8]}px;
`;
const availableOfferBoxCss = css`
${textSans17};
border: 1px solid ${palette.neutral[86]};
display: flex;
flex-wrap: wrap;
margin: ${space[5]}px 0 ${space[8]}px;
width: 100%;
position: relative;
${from.tablet} {
border: none;
}
`;
const offerBoxWithoutImageCss = css`
${from.tablet} {
border: 1px solid ${palette.neutral[93]};
}
`;
const availableOfferBoxInnerCss = css`
padding: 0 ${space[4]}px ${space[5]}px;
width: 100%;
${from.tablet} {
background-color: ${palette.neutral[100]};
width: 363px;
padding: var(--offerBoxTopPadding) ${space[6]}px ${space[5]}px;
margin: ${space[6]}px;
}
`;
const offerBoxInnerWithoutImageCss = css`
padding: ${space[4]}px;
${from.tablet} {
width: 410px;
padding: ${space[6]}px;
margin: 0;
}
`;
const headerImageCss = css`
display: flex;
justify-content: center;
width: 100%;
height: auto;
background-color: ${palette.culture[800]};
${from.tablet} {
position: absolute;
z-index: -1;
height: 100%;
overflow: hidden;
justify-content: flex-start;
img {
height: 100%;
margin-left: 389px;
}
}
`;
const pillCss = css`
transform: translateY(-50%);
margin-left: ${space[4]}px;
${from.tablet} {
margin-left: 0;
position: absolute;
top: ${space[6]}px;
left: ${space[12]}px;
}
`;
const strikethroughPriceCss = css`
${textSans17};
color: ${neutral[46]};
margin: 0;
`;
const discountedPriceSpan = css`
${textSansBold20};
color: ${neutral[0]};
`;
const offerBoxTitleCss = css`
color: ${neutral[7]};
margin: 0;
`;
const billingResumptionDateCss = css`
${textSans12};
color: ${neutral[38]};
margin: 0;
`;
const billingResumptionDatePercentageOfferCss = css`
margin-bottom: ${space[6]}px;
${from.tablet} {
margin-bottom: ${space[5]}px;
}
`;
const offerButtonCss = css`
margin: ${space[5]}px 0 ${space[6]}px;
width: 100%;
justify-content: center;
${from.tablet} {
margin-bottom: ${space[5]}px;
}
`;
const offerButtonSmallBottomMargin = css`
margin-bottom: ${space[2]}px;
${from.tablet} {
margin-bottom: ${space[2]}px;
}
`;
const benefitsSubTitleCss = css`
margin: 0 0 ${space[3]}px;
${textSansBold15};
${from.tablet} {
border-top: 1px solid ${palette.neutral[86]};
padding-top: ${space[3]}px;
margin-bottom: ${space[4]}px;
}
`;
const cancelBtnHolderCss = css`
${from.phablet} {
display: flex;
justify-content: space-between;
}
`;
const cancelButtonCss = css`
margin: 0 0 ${space[3]}px;
width: 100%;
justify-content: center;
${from.tablet} {
width: fit-content;
}
`;
const termsCss = css`
${textSans12};
color: ${palette.neutral[46]};
margin-top: ${space[3]}px;
`;
export const CancelAlternativeOffer = () => {
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 nextNonDiscountedPaymentDate = parseDate(
routerState.nextNonDiscountedPaymentDate,
'yyyy-MM-dd',
).dateStr(DATE_FNS_LONG_OUTPUT_FORMAT);
const humanReadableStrikethroughPrice = getMaxNonDiscountedPrice(
routerState.nonDiscountedPayments,
true,
);
const alternativeIsOffer = productType.productType === 'supporterplus';
const alternativeIsPause = productType.productType === 'contributions';
const offerIsPercentageOrFree: 'percentage' | 'free' | false =
alternativeIsOffer &&
(routerState.discountPercentage < 100 ? 'percentage' : 'free');
const standfirstCopy: Partial<Record<ProductTypeKeys, string>> = {
supporterplus:
offerIsPercentageOrFree === 'percentage'
? `Instead of cancelling, take ${routerState.discountPercentage}% off and keep enjoying all your existing benefits.`
: `Instead of cancelling, enjoy ${offerPeriodWord} ${offerPeriodType} with all your existing benefits${
offerIsPercentageOrFree === 'free' ? ' — for free' : ''
}.`,
contributions: `Instead of cancelling, you can pause your recurring payment for ${offerPeriodWord} ${offerPeriodType}.`,
};
const heroImageSrc: { mobile: string; desktop: string } = {
mobile: alternativeIsOffer
? 'https://media.guim.co.uk/63d17ee19313703129fbbeacceaafcd6d1cc1014/0_0_1404_716/500.png'
: '',
desktop: alternativeIsOffer
? 'https://i.guim.co.uk/img/media/02c17de8ea17126fbd87f6567ce5cd80f128546d/0_0_2212_1869/2000.png?width=1000&quality=75&s=492edad637979aa4e57e957cb12cd4f1'
: '',
};
const withHeroImage =
Boolean(heroImageSrc.mobile) && Boolean(heroImageSrc.desktop);
return (
<>
<ProgressStepper
steps={[{}, {}, { isCurrentStep: true }, {}]}
additionalCSS={css`
margin: ${space[8]}px 0 ${space[9]}px;
`}
/>
<Heading
borderless
cssOverrides={[
measure.heading,
css`
margin-bottom: ${space[2]}px;
`,
]}
>
This doesn't have to be goodbye
</Heading>
<h3 css={standfirstCss}>
{standfirstCopy[productType.productType]}
</h3>
<div
css={[
availableOfferBoxCss,
!withHeroImage && offerBoxWithoutImageCss,
]}
>
{withHeroImage && (
<picture css={headerImageCss}>
<source
srcSet={heroImageSrc.desktop}
media="(min-width: 740px)"
/>
<img src={heroImageSrc.mobile} />
</picture>
)}
{alternativeIsOffer && (
<Pill
copy="Your one-time offer"
colour={
offerIsPercentageOrFree === 'percentage'
? palette.news[400]
: palette.brand[500]
}
additionalCss={pillCss}
/>
)}
<div
css={[
availableOfferBoxInnerCss,
!withHeroImage && offerBoxInnerWithoutImageCss,
]}
style={{
['--offerBoxTopPadding' as string]: alternativeIsOffer
? `${space[8]}px`
: `${space[4]}px`,
}}
>
{offerIsPercentageOrFree === 'percentage' && (
<h4
css={[
offerBoxTitleCss,
css`
${textSansBold24}
`,
]}
>
{routerState.discountPercentage}% off for{' '}
{getAppropriateReadableTimePeriod(
routerState.upToPeriods,
offerPeriodType,
)}
</h4>
)}
{alternativeIsOffer && isPaidSubscriptionPlan(mainPlan) && (
<p css={strikethroughPriceCss}>
<s>
{mainPlan.currency}
{humanReadableStrikethroughPrice}/
{mainPlan.billingPeriod}
</s>
{offerIsPercentageOrFree === 'percentage' && (
<span css={discountedPriceSpan}>
{' '}
{mainPlan.currency}
{routerState.discountedPrice}/
{mainPlan.billingPeriod}
</span>
)}
</p>
)}
{alternativeIsPause && (
<>
<h4
css={[
offerBoxTitleCss,
css`
${textSansBold20}
`,
]}
>
Would you like to pause your support to the
Guardian for {offerPeriodWord} {offerPeriodType}
?
</h4>
<p css={billingResumptionDateCss}>
Billing resumes on{' '}
{nextNonDiscountedPaymentDate}
</p>
</>
)}
{offerIsPercentageOrFree === 'free' && (
<h4
css={[
offerBoxTitleCss,
css`
${textSansBold28}
`,
]}
>
{capitalize(offerPeriodWord)} {offerPeriodType} free
</h4>
)}
{offerIsPercentageOrFree === 'free' && (
<p css={billingResumptionDateCss}>
Billing resumes on {nextNonDiscountedPaymentDate}
</p>
)}
<Button
onClick={() => {
const reviewUrlPart = `../${
(alternativeIsOffer && 'offer-review') || ''
}${(alternativeIsPause && 'pause-review') || ''}`;
navigate(reviewUrlPart, {
state: routerState,
});
}}
cssOverrides={[
offerButtonCss,
css`
${offerIsPercentageOrFree === 'percentage'
? offerButtonSmallBottomMargin
: ''}
`,
]}
>
{alternativeIsOffer && 'Redeem your offer'}
{alternativeIsPause && 'Yes, pause my support'}
</Button>
{offerIsPercentageOrFree === 'percentage' &&
isPaidSubscriptionPlan(mainPlan) && (
<p
css={[
billingResumptionDateCss,
billingResumptionDatePercentageOfferCss,
]}
>
You will pay {mainPlan.currency}
{routerState.discountedPrice} for the next{' '}
{routerState.upToPeriods} {offerPeriodType} then{' '}
{mainPlan.currency}
{getMaxNonDiscountedPrice(
routerState.nonDiscountedPayments,
true,
)}
/{mainPlan.billingPeriod}
</p>
)}
{alternativeIsOffer && (
<>
<p css={benefitsSubTitleCss}>
Keep your existing benefits:
</p>
<div
css={css`
max-width: 290px;
`}
>
<BenefitsSection
small
benefits={[
{
description:
'Unlimited access to the Guardian app',
},
{
description:
'Unlimited access to the Guardian Feast App',
},
{
description:
'Ad-free reading across all your devices',
},
{
description:
'Exclusive supporter newsletter',
},
{
description:
"Far fewer asks for support when you're signed in",
},
]}
/>
</div>
</>
)}
</div>
</div>
<div css={cancelBtnHolderCss}>
<Button
priority="tertiary"
cssOverrides={cancelButtonCss}
onClick={() => {
navigate('../confirm', {
state: {
...routerState,
eligibleForFreePeriodOffer: alternativeIsOffer,
eligibleForPause: alternativeIsPause,
},
});
}}
>
No thanks, continue to cancel
</Button>
<Button
priority="subdued"
cssOverrides={cancelButtonCss}
onClick={() => {
navigate('/');
}}
>
Return to your account
</Button>
</div>
{isPaidSubscriptionPlan(mainPlan) && (
<p css={termsCss}>
Your {mainPlan.billingPeriod}ly payments of{' '}
{mainPlan.currency}
{humanReadableStrikethroughPrice} will automatically resume
on {nextNonDiscountedPaymentDate} unless you cancel.
{alternativeIsOffer && (
<>
{' '}
Cannot be used together with any other subscription
offer you may currently have.
</>
)}
</p>
)}
</>
);
};