client/components/mma/cancel/CancellationReasonReview.tsx (668 lines of code) (raw):
import { css } from '@emotion/react';
import {
palette,
space,
textSans14,
until,
} from '@guardian/source/foundations';
import {
Button,
InlineError,
Spinner,
SvgArrowRightStraight,
} from '@guardian/source/react-components';
import type { ChangeEvent, FC } from 'react';
import { useContext, useEffect, useState } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import type {
DiscountPeriodType,
DiscountPreviewResponse,
} from '@/client/utilities/discountPreview';
import { fetchWithDefaultParameters } from '@/client/utilities/fetch';
import { cancelAlternativeUrlPartLookup } from '@/shared/cancellationUtilsAndTypes';
import { featureSwitches } from '@/shared/featureSwitches';
import type { TrueFalsePending } from '@/shared/generalTypes';
import { appendCorrectPluralisation } from '@/shared/generalTypes';
import { DATE_FNS_INPUT_FORMAT, parseDate } from '../../../../shared/dates';
import type { ProductDetail } from '../../../../shared/productResponse';
import { MDA_TEST_USER_HEADER } from '../../../../shared/productResponse';
import type {
ProductTypeWithCancellationFlow,
ProductTypeWithCancellationFlowMandatoryReasons,
WithProductType,
} from '../../../../shared/productTypes';
import { measure } from '../../../styles/typography';
import { useFetch } from '../../../utilities/hooks/useFetch';
import { GenericErrorScreen } from '../../shared/GenericErrorScreen';
import { Spinner as SpinnerWithMessage } from '../../shared/Spinner';
import { WithStandardTopMargin } from '../../shared/WithStandardTopMargin';
import type {
DeliveryRecordDetail,
DeliveryRecordsResponse,
} from '../delivery/records/deliveryRecordsApi';
import type {
OutstandingHolidayStop,
OutstandingHolidayStopsResponse,
} from '../holiday/HolidayStopApi';
import { Heading } from '../shared/Heading';
import { ProgressIndicator } from '../shared/ProgressIndicator';
import { ProgressStepper } from '../shared/ProgressStepper';
import type { CancellationContextInterface } from './CancellationContainer';
import { CancellationContext } from './CancellationContainer';
import { cancellationEffectiveToday } from './cancellationContexts';
import { requiresCancellationEscalation } from './cancellationFlowEscalationCheck';
import type {
CancellationReason,
CancellationReasonId,
OptionalCancellationReasonId,
SaveBodyProps,
} from './cancellationReason';
import { CaseUpdateAsyncLoader, getUpdateCasePromise } from './caseUpdate';
const getPatchUpdateCaseFunc =
(isTestUser: boolean, caseId: string, feedback: string) => async () =>
await getUpdateCasePromise(isTestUser, '_FEEDBACK', caseId, {
Description: feedback,
Subject: 'Online Cancellation Query',
});
const ContactUs = (reason: CancellationReason) =>
reason.hideContactUs ? (
<></>
) : (
<p
css={css`
margin: 0;
`}
>
If you have any questions, feel free to{' '}
{
<Link
to="/help-centre#contact-options"
css={css`
text-decoration: underline;
color: ${palette.brand[500]};
`}
>
contact our support team
</Link>
}
.
</p>
);
interface FeedbackFormProps
extends WithProductType<ProductTypeWithCancellationFlow> {
reason: CancellationReason;
characterLimit: number;
caseId: string;
isTestUser: boolean;
holidayStops?: OutstandingHolidayStop[];
deliveryCredits?: DeliveryRecordDetail[];
}
const FeedbackFormAndContactUs = (props: FeedbackFormProps) => {
const [feedback, setFeedback] = useState<string>('');
const [hasHitSubmit, setHasHitSubmit] = useState<boolean>(false);
const [inFeedbackValidationErrorState, setFeedbackValidationErrorState] =
useState<boolean>(false);
const getFeedbackThankYouRenderer = (reason: CancellationReason) => {
return () => (
<div
css={{
marginLeft: '15px',
marginTop: '30px',
paddingLeft: '15px',
borderLeft: '1px solid ' + palette.neutral[60],
}}
>
<p
css={{
fontSize: '1rem',
fontWeight: 500,
}}
>
{reason.alternateFeedbackThankYouTitle ||
'Thank you for your feedback.'}
</p>
<span>
{reason.alternateFeedbackThankYouBody ||
'The Guardian is dedicated to keeping our independent, investigative journalism open to all. We report on the facts, challenging the powerful and holding them to account. Support from our readers makes what we do possible and we appreciate hearing from you to help improve our service.'}
</span>
</div>
);
};
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setFeedback(event.target.value);
setFeedbackValidationErrorState(false);
};
const submitFeedback = () => {
if (feedback.length) {
setHasHitSubmit(true);
}
setFeedbackValidationErrorState(!feedback.length);
};
return hasHitSubmit ? (
<>
<CaseUpdateAsyncLoader
loadingMessage="Storing your feedback..."
fetch={getPatchUpdateCaseFunc(
props.isTestUser,
props.caseId,
feedback,
)}
render={getFeedbackThankYouRenderer(props.reason)}
/>
<div css={{ height: '20px' }} />
<ConfirmCancellationAndReturnRow
hide={!!props.reason.hideSaveActions}
reasonId={props.reason.reasonId}
productType={props.productType}
caseId={props.caseId}
holidayStops={props.holidayStops}
deliveryCredits={props.deliveryCredits}
/>
</>
) : (
<>
<div>
{!props.reason.hideContactUs &&
!props.productType.cancellation
.swapFeedbackAndContactUs && (
<ContactUs {...props.reason} />
)}
<p>
{props.reason.alternateFeedbackIntro ||
'Alternatively provide feedback in the box below'}
</p>
<textarea
rows={5}
maxLength={props.characterLimit}
css={{
width: '100%',
fontSize: 'inherit',
fontFamily: 'inherit',
border: '1px black solid',
}}
onChange={handleChange}
/>
<div css={{ textAlign: 'right' }}>
<div
css={css`
${textSans14};
color: ${palette.neutral[46]};
padding-bottom: 10px;
`}
>
You have {props.characterLimit - feedback.length}{' '}
characters remaining
</div>
{inFeedbackValidationErrorState && (
<InlineError
cssOverrides={css`
padding: ${space[5]}px;
margin-bottom: ${space[4]}px;
border: 4px solid ${palette.error[400]};
text-align: left;
`}
>
Please insert your feedback into the textbox before
submitting. Otherwise select ‘Confirm cancellation’
to continue
</InlineError>
)}
<Button priority="secondary" onClick={submitFeedback}>
Submit feedback
</Button>
<ConfirmCancellationAndReturnRow
hide={!!props.reason.hideSaveActions}
reasonId={props.reason.reasonId}
productType={props.productType}
caseId={props.caseId}
holidayStops={props.holidayStops}
deliveryCredits={props.deliveryCredits}
onClick={() => {
if (feedback.length > 0) {
getPatchUpdateCaseFunc(
props.isTestUser,
props.caseId,
feedback,
)();
}
}}
/>
{!props.reason.hideContactUs &&
props.productType.cancellation
.swapFeedbackAndContactUs && (
<div css={{ marginTop: '20px' }}>
<ContactUs {...props.reason} />
</div>
)}
</div>
</div>
</>
);
};
interface ConfirmCancellationAndReturnRowProps
extends WithProductType<ProductTypeWithCancellationFlow> {
onClick?: () => void;
hide?: boolean;
reasonId: CancellationReasonId;
caseId: string;
holidayStops?: OutstandingHolidayStop[];
deliveryCredits?: DeliveryRecordDetail[];
}
const ConfirmCancellationAndReturnRow = (
props: ConfirmCancellationAndReturnRowProps,
) => {
const location = useLocation();
const routerState = location.state as {
selectedReasonId: OptionalCancellationReasonId;
cancellationPolicy: string;
};
const navigate = useNavigate();
const { productDetail, productType } = useContext(
CancellationContext,
) as CancellationContextInterface;
const isSupporterPlusAndFreePeriodOfferIsActive =
featureSwitches.supporterplusCancellationOffer &&
productType.productType === 'supporterplus';
const isContributionAndBreakFeatureIsActive =
featureSwitches.contributionCancellationPause &&
productType.productType === 'contributions';
const [
showAlternativeBeforeCancelling,
setShowAlternativeBeforeCancelling,
] = useState<TrueFalsePending>(
isSupporterPlusAndFreePeriodOfferIsActive ||
isContributionAndBreakFeatureIsActive
? 'pending'
: false,
);
const [discountPreviewDetails, setDiscountPreviewDetails] =
useState<DiscountPreviewResponse | null>(null);
const productHasAlternativeRecommendation =
productType.productType === 'supporterplus' ||
productType.productType === 'contributions';
const sanitizeOfferData = (
offerData: DiscountPreviewResponse,
): DiscountPreviewResponse => {
if (offerData.upToPeriodsType) {
return {
...offerData,
upToPeriodsType: appendCorrectPluralisation(
offerData.upToPeriodsType,
offerData.upToPeriods,
) as DiscountPeriodType,
};
}
return offerData;
};
useEffect(() => {
if (
isSupporterPlusAndFreePeriodOfferIsActive ||
isContributionAndBreakFeatureIsActive
) {
(async () => {
try {
const response = await fetchWithDefaultParameters(
'/api/discounts/preview-discount',
{
method: 'POST',
body: JSON.stringify({
subscriptionNumber:
productDetail.subscription.subscriptionId,
}),
},
);
if (response.ok) {
// api returns a 400 response if the user is not eligible
setShowAlternativeBeforeCancelling(true);
const offerData = await response.json();
const sanitizedOfferData = sanitizeOfferData(offerData);
setDiscountPreviewDetails(sanitizedOfferData);
} else {
setShowAlternativeBeforeCancelling(false);
}
} catch {
setShowAlternativeBeforeCancelling(false);
}
})();
}
}, [
isContributionAndBreakFeatureIsActive,
isSupporterPlusAndFreePeriodOfferIsActive,
productDetail.subscription.subscriptionId,
]);
return (
<>
{!props.hide && (
<div
css={{
display: 'flex',
justifyContent: 'space-between',
flexDirection: 'row-reverse',
marginTop: '10px',
textAlign: 'left',
[until.mobileLandscape]: {
flexDirection: 'column',
},
}}
>
<div
css={{
textAlign: 'right',
marginBottom: '30px',
}}
>
<Button
icon={
showAlternativeBeforeCancelling ===
'pending' ? (
<Spinner size="xsmall" />
) : (
<SvgArrowRightStraight />
)
}
iconSide="right"
disabled={
showAlternativeBeforeCancelling === 'pending'
}
aria-disabled={
showAlternativeBeforeCancelling === 'pending'
}
onClick={() => {
if (props.onClick) {
props.onClick();
}
if (showAlternativeBeforeCancelling) {
const cancelAlternativeUrlPart =
cancelAlternativeUrlPartLookup[
productType.productType
] || '';
navigate(`../${cancelAlternativeUrlPart}`, {
state: {
...routerState,
...discountPreviewDetails,
caseId: props.caseId,
holidayStops: props.holidayStops,
deliveryCredits:
props.deliveryCredits,
},
});
} else {
navigate(
productHasAlternativeRecommendation
? '../confirm'
: '../confirmed',
{
state: {
...routerState,
eligibleForFreePeriodOffer:
false,
caseId: props.caseId,
holidayStops:
props.holidayStops,
deliveryCredits:
props.deliveryCredits,
},
},
);
}
}}
>
{productHasAlternativeRecommendation
? 'Continue to cancellation'
: 'Confirm cancellation'}
</Button>
</div>
<div>
<Button
priority="tertiary"
onClick={() => navigate('/')}
>
Return to your account
</Button>
</div>
</div>
)}
</>
);
};
export const CancellationReasonReview = () => {
const location = useLocation();
const routerState = location.state as {
selectedReasonId: OptionalCancellationReasonId;
cancellationPolicy: string;
};
const { productDetail, productType } = useContext(
CancellationContext,
) as CancellationContextInterface;
if (!routerState?.selectedReasonId || !productType?.cancellation.reasons) {
return <Navigate to=".." />;
}
return (
<ValidatedCancellationReasonReview
productDetail={productDetail}
productType={
productType as ProductTypeWithCancellationFlowMandatoryReasons
}
/>
);
};
const ValidatedCancellationReasonReview = ({
productDetail,
productType,
}: {
productDetail: ProductDetail;
productType: ProductTypeWithCancellationFlowMandatoryReasons;
}) => {
const location = useLocation();
const routerState = location.state as {
selectedReasonId: OptionalCancellationReasonId;
cancellationPolicy: string;
};
const { selectedReasonId, cancellationPolicy } = routerState;
const reason = productType.cancellation.reasons.find(
(reason) => reason.reasonId === selectedReasonId,
) as CancellationReason;
const effectiveCancellationDate =
!productDetail.subscription?.chargedThroughDate ||
cancellationPolicy === cancellationEffectiveToday
? parseDate()
: parseDate(productDetail.subscription.chargedThroughDate);
const holidayStopCreditApiUrl =
productType.cancellation.checkForOutstandingCredits &&
`/api/holidays/${
productDetail.subscription.subscriptionId
}/cancel?effectiveCancellationDate=${effectiveCancellationDate.dateStr(
DATE_FNS_INPUT_FORMAT,
)}`;
const deliveryProblemCreditApiUrl =
productType.cancellation.checkForOutstandingCredits &&
`/api/delivery-records/${
productDetail.subscription.subscriptionId
}/cancel?effectiveCancellationDate=${effectiveCancellationDate.dateStr(
DATE_FNS_INPUT_FORMAT,
)}`;
const holidayStopCreditFetch = useFetch<OutstandingHolidayStopsResponse>(
holidayStopCreditApiUrl,
{
headers: {
[MDA_TEST_USER_HEADER]: `${productDetail.isTestUser}`,
},
},
);
const deliveryProblemCreditFetch = useFetch<DeliveryRecordsResponse>(
deliveryProblemCreditApiUrl,
{
headers: {
[MDA_TEST_USER_HEADER]: `${productDetail.isTestUser}`,
},
},
);
const cancellationCaseFetch = useFetch<{ id: string }>('/api/case', {
method: 'POST',
body: JSON.stringify({
reason: selectedReasonId,
product: productType.cancellation.sfCaseProduct,
subscriptionName: productDetail.subscription.subscriptionId,
gaData: '',
}),
headers: {
'Content-Type': 'application/json',
[MDA_TEST_USER_HEADER]: `${productDetail.isTestUser}`,
},
});
const caseId = cancellationCaseFetch.data?.id || '';
const isLoading = () =>
(productType.cancellation.checkForOutstandingCredits &&
(!holidayStopCreditFetch.data ||
!deliveryProblemCreditFetch.data)) ||
!cancellationCaseFetch.data;
const loadingHasFailed =
(productType.cancellation.checkForOutstandingCredits &&
holidayStopCreditFetch.error) ||
deliveryProblemCreditFetch.error ||
cancellationCaseFetch.error;
const needsCancellationEscalation = requiresCancellationEscalation(
holidayStopCreditFetch.data?.publicationsToRefund,
deliveryProblemCreditFetch.data?.results,
routerState.cancellationPolicy,
);
const renderSaveBody = (
saveBody: string[] | React.FC<SaveBodyProps>,
caseId: string,
holidayStops?: OutstandingHolidayStop[],
deliveryCredits?: DeliveryRecordDetail[],
) => {
if (saveBody.length && typeof saveBody === 'object') {
<>
{saveBody.map((saveBodyParagraph, index) => (
<p key={`save_body_${index}`}>{saveBodyParagraph}</p>
))}
</>;
return <p id="save_body">{saveBody}</p>;
}
const SaveBody = saveBody as FC<SaveBodyProps>;
return (
<SaveBody
caseId={caseId}
holidayStops={holidayStops}
deliveryCredits={deliveryCredits}
/>
);
};
const shouldUseProgressStepper =
(featureSwitches.supporterplusCancellationOffer &&
productType.productType === 'supporterplus') ||
(featureSwitches.contributionCancellationPause &&
productType.productType === 'contributions');
return (
<>
{shouldUseProgressStepper ? (
<ProgressStepper
steps={[{}, { isCurrentStep: true }, {}, {}]}
additionalCSS={css`
margin: ${space[5]}px 0 ${space[12]}px;
`}
/>
) : (
<ProgressIndicator
steps={[
{ title: 'Reason' },
{ title: 'Review', isCurrentStep: true },
{ title: 'Confirmation' },
]}
additionalCSS={css`
margin: ${space[5]}px 0 ${space[12]}px;
`}
/>
)}
<WithStandardTopMargin>
{isLoading() ? (
!loadingHasFailed && (
<SpinnerWithMessage loadingMessage="Checking details" />
)
) : (
<>
<Heading
cssOverrides={[
measure.heading,
css`
margin-bottom: ${space[6]}px;
`,
]}
>
{productType.cancellation.hideReasonTitlePrefix
? ''
: 'Reason: '}
{reason.saveTitle || reason.linkLabel}
</Heading>
{needsCancellationEscalation && (
<p>
Once you submit your cancellation request our
customer service team will try their best to
contact you as soon as possible to confirm the
cancellation and refund any credit you are owed.
</p>
)}
{reason.saveBody &&
renderSaveBody(
reason.saveBody,
caseId,
holidayStopCreditFetch.data
?.publicationsToRefund,
deliveryProblemCreditFetch.data?.results,
)}
{needsCancellationEscalation &&
reason.escalationSaveBody &&
renderSaveBody(
reason.escalationSaveBody,
caseId,
holidayStopCreditFetch.data
?.publicationsToRefund,
deliveryProblemCreditFetch.data?.results,
)}
{caseId && !reason.skipFeedback ? (
<FeedbackFormAndContactUs
characterLimit={2500}
caseId={caseId}
holidayStops={
holidayStopCreditFetch.data
?.publicationsToRefund
}
deliveryCredits={
deliveryProblemCreditFetch.data?.results
}
reason={reason}
productType={productType}
isTestUser={productDetail.isTestUser}
/>
) : (
<div
css={{
display: 'flex',
flexDirection:
productType.cancellation
.swapFeedbackAndContactUs && caseId
? 'column-reverse'
: 'column',
}}
>
<ContactUs {...reason} />
<ConfirmCancellationAndReturnRow
hide={!!reason.hideSaveActions}
reasonId={reason.reasonId}
productType={productType}
caseId={caseId}
holidayStops={
holidayStopCreditFetch.data
?.publicationsToRefund
}
deliveryCredits={
deliveryProblemCreditFetch.data?.results
}
/>
</div>
)}
</>
)}
{loadingHasFailed && (
<GenericErrorScreen loggingMessage="Cancel journey case id, holiday stop credits or delivery problem credits api call failed during the cancellation process" />
)}
</WithStandardTopMargin>
</>
);
};