client/components/mma/holiday/HolidayDateChooser.tsx (517 lines of code) (raw):
import { css } from '@emotion/react';
import {
from,
palette,
space,
textSans14,
textSansBold17,
until,
} from '@guardian/source/foundations';
import { Button, InlineError } from '@guardian/source/react-components';
import * as Sentry from '@sentry/browser';
import { startCase } from 'lodash';
import { createContext, useContext, useEffect, useState } from 'react';
import type * as React from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import type { DateRange } from '../../../../shared/dates';
import {
DATE_FNS_LONG_OUTPUT_FORMAT,
dateAddYears,
dateRange,
dateString,
parseDate,
} from '../../../../shared/dates';
import { isProduct } from '../../../../shared/productResponse';
import { trackEvent } from '../../../utilities/analytics';
import { GenericErrorScreen } from '../../shared/GenericErrorScreen';
import { InfoIcon } from '../shared/assets/InfoIcon';
import { DatePicker } from '../shared/DatePicker';
import { ProgressIndicator } from '../shared/ProgressIndicator';
import { HolidayAnniversaryDateExplainerModal } from './HolidayAnniversaryDateExplainerModal';
import {
creditExplainerSentence,
HolidayQuestionsModal,
} from './HolidayQuestionsModal';
import { HolidaySelectionInfo } from './HolidaySelectionInfo';
import type {
HolidayStopDetail,
HolidayStopRequest,
IssuesImpactedPerYear,
PotentialHolidayStopsResponse,
} from './HolidayStopApi';
import {
calculateIssuesImpactedPerYear,
convertRawPotentialHolidayStopDetail,
getPotentialHolidayStopsFetcher,
isHolidayStopsResponse,
isNotBulkSuspension,
isNotWithdrawn,
} from './HolidayStopApi';
import type {
HolidayStopsContextInterface,
HolidayStopsRouterState,
} from './HolidayStopsContainer';
import { HolidayStopsContext } from './HolidayStopsContainer';
export const cancelLinkCss = css`
margin-right: ${space[5]}px;
${textSansBold17};
textdecoration: underline;
color: ${palette.neutral['7']};
`;
export const buttonBarCss = css`
display: flex;
align-items: center;
margin-top: 40px;
flex-wrap: wrap;
`;
const oneAtATimeStyles = css`
${textSans14};
margin-bottom: 27px;
`;
const fixedButtonFooterCss = css`
${until.mobileLandscape} {
justify-content: space-between;
}
${until.phablet} {
position: fixed;
z-index: 998;
bottom: 0;
left: 0;
right: 0;
background: ${palette.neutral[100]};
padding: 10px;
box-shadow: 0 0 5px ${palette.neutral[60]};
} ;
`;
export interface SharedHolidayDateChooserState {
selectedRange: DateRange;
publicationsImpacted: HolidayStopDetail[];
}
const extractMaybeLockedStartDate = (
existingHolidayStopToAmend: HolidayStopRequest | null,
) =>
!!existingHolidayStopToAmend &&
existingHolidayStopToAmend.mutabilityFlags &&
!existingHolidayStopToAmend.mutabilityFlags.isFullyMutable &&
existingHolidayStopToAmend.mutabilityFlags.isEndDateEditable
? existingHolidayStopToAmend.dateRange.start
: null;
export function isSharedHolidayDateChooserState(
state: HolidayStopRequest[] | SharedHolidayDateChooserState,
): state is SharedHolidayDateChooserState {
return (
state.hasOwnProperty('selectedRange') &&
state.hasOwnProperty('publicationsImpacted')
);
}
const validateIssuesSelected = (
renewalDate: Date,
annualIssueLimit: number,
numPotentialIssuesThisYear: number,
issuesRemainingThisYear: number,
numPotentialIssuesNextYear: number,
issuesRemainingNextYear: number,
issueKeyword: string,
): React.ReactNode => {
const dateElement = (
<>{dateString(renewalDate, DATE_FNS_LONG_OUTPUT_FORMAT)}*</>
);
if (numPotentialIssuesThisYear > issuesRemainingThisYear) {
return (
<>
Exceeded {issueKeyword} limit of {annualIssueLimit} before{' '}
{dateElement}{' '}
<HolidayAnniversaryDateExplainerModal
dateElement={dateElement}
issueKeyword={issueKeyword}
/>
<br />
Please choose fewer/different days...
</>
);
} else if (numPotentialIssuesNextYear > issuesRemainingNextYear) {
return (
<>
Exceeded {issueKeyword} limit of {annualIssueLimit} between{' '}
{dateElement} and{' '}
{dateString(
dateAddYears(renewalDate, 1),
DATE_FNS_LONG_OUTPUT_FORMAT,
)}
{'* '}
<HolidayAnniversaryDateExplainerModal
dateElement={dateElement}
issueKeyword={issueKeyword}
/>
<br />
Please choose fewer/different days...
</>
);
} else if (
numPotentialIssuesThisYear < 1 &&
numPotentialIssuesNextYear < 1
) {
return `No ${issueKeyword}s occur during selected period`;
}
return null; // important don't remove
};
export const HolidayDateChooserStateContext = createContext<
SharedHolidayDateChooserState | object
>({});
interface HolidayDateChooserProps {
isAmendJourney?: true;
}
export const HolidayDateChooser = (props: HolidayDateChooserProps) => {
const {
productDetail,
productType,
existingHolidayStopToAmend,
selectedRange,
setSelectedRange,
publicationsImpacted,
setPublicationsImpacted,
holidayStopResponse,
} = useContext(HolidayStopsContext) as HolidayStopsContextInterface;
const [
issuesImpactedPerYearBySelection,
setIssuesImpactedPerYearBySelection,
] = useState<IssuesImpactedPerYear | null>(null);
const [validationErrorMessage, setValidationErrorMessage] =
useState<React.ReactNode | null>(null);
const [showReviewWarning, setShowReviewWarning] = useState<boolean>(false);
const navigate = useNavigate();
const location = useLocation();
const routerState = location.state as HolidayStopsRouterState;
useEffect(() => {
if (
isHolidayStopsResponse(holidayStopResponse) &&
existingHolidayStopToAmend
) {
const maybeLockedStartDate = extractMaybeLockedStartDate(
existingHolidayStopToAmend,
);
setSelectedRange(existingHolidayStopToAmend.dateRange);
setValidationErrorMessage(
`Please select your new ${
maybeLockedStartDate
? 'end date (the start date is locked because it is within notice period) '
: 'dates'
}...`,
);
}
}, [existingHolidayStopToAmend, holidayStopResponse, setSelectedRange]);
const onChange =
(
renewalDate: Date,
subscriptionName: string,
combinedIssuesImpactedPerYear: IssuesImpactedPerYear,
allIssuesImpactedPerYear: IssuesImpactedPerYear,
annualIssueLimit: number,
isTestUser: boolean,
) =>
({ startDate, endDate }: { startDate: Date; endDate: Date }) => {
const newSelectedRange = dateRange(startDate, endDate);
setSelectedRange(newSelectedRange);
setIssuesImpactedPerYearBySelection(null);
setValidationErrorMessage(null);
getPotentialHolidayStopsFetcher(
subscriptionName,
startDate,
endDate,
isTestUser,
)()
.then((response) => {
const locationHeader = response.headers.get('Location');
if (
response.status === 401 &&
locationHeader &&
window !== undefined
) {
window.location.replace(locationHeader);
return Promise.resolve([]);
} else if (response.ok) {
return response.json();
}
return Promise.reject(
new Error(`${response.status} from holiday-stop-api`),
);
})
.then(({ potentials }: PotentialHolidayStopsResponse) => {
const updatePublicationsImpacted: HolidayStopDetail[] =
potentials.map(convertRawPotentialHolidayStopDetail);
const updateIssuesImpactedPerYearBySelection =
calculateIssuesImpactedPerYear(
updatePublicationsImpacted,
renewalDate,
);
const issuesRemainingThisYear =
Math.max(
annualIssueLimit,
allIssuesImpactedPerYear.issuesThisYear.length,
) - combinedIssuesImpactedPerYear.issuesThisYear.length;
const issuesRemainingNextYear =
Math.max(
annualIssueLimit,
allIssuesImpactedPerYear.issuesNextYear.length,
) - combinedIssuesImpactedPerYear.issuesNextYear.length;
setPublicationsImpacted(updatePublicationsImpacted);
setIssuesImpactedPerYearBySelection(
updateIssuesImpactedPerYearBySelection,
);
const newValidationErrorMessage = validateIssuesSelected(
renewalDate,
annualIssueLimit,
updateIssuesImpactedPerYearBySelection.issuesThisYear
.length,
issuesRemainingThisYear,
updateIssuesImpactedPerYearBySelection.issuesNextYear
.length,
issuesRemainingNextYear,
productType.holidayStops.issueKeyword,
);
setValidationErrorMessage(newValidationErrorMessage);
if (showReviewWarning) {
setShowReviewWarning(
!!newValidationErrorMessage ||
!newSelectedRange ||
!updateIssuesImpactedPerYearBySelection,
);
}
})
.catch((error) => {
setValidationErrorMessage(
`Failed to calculate ${productType.holidayStops.issueKeyword}s impacted by selected dates. Please try again later...`,
);
trackEvent({
eventCategory: 'holidayDateChooser',
eventAction: 'error',
eventLabel: error ? error.toString() : undefined,
});
Sentry.captureException(error);
});
};
const holidayStopResponseIsValid =
isHolidayStopsResponse(holidayStopResponse);
if (holidayStopResponseIsValid) {
if (isProduct(productDetail)) {
const existingHolidayStopToAmendId = existingHolidayStopToAmend?.id;
const anniversaryDate = parseDate(
productDetail.subscription.anniversaryDate,
).date;
const combinedIssuesImpactedPerYear =
calculateIssuesImpactedPerYear(
holidayStopResponse.existing
.filter(isNotWithdrawn)
.filter(isNotBulkSuspension)
.filter((_) => _.id !== existingHolidayStopToAmendId)
.flatMap((_) => _.publicationsImpacted),
anniversaryDate,
);
const allIssuesImpactedPerYear = calculateIssuesImpactedPerYear(
holidayStopResponse.existing
.filter(isNotWithdrawn)
.filter(isNotBulkSuspension)
.flatMap((_) => _.publicationsImpacted),
anniversaryDate,
);
return (
<>
<ProgressIndicator
steps={[
{ title: 'Choose dates', isCurrentStep: true },
{ title: 'Review' },
{ title: 'Confirmation' },
]}
additionalCSS={css`
margin: ${space[5]}px 0 ${space[12]}px;
`}
/>
{props.isAmendJourney && !existingHolidayStopToAmend && (
<Navigate to=".." state={routerState} />
)}
<h1>Choose the dates you will be away</h1>
<p>
The first available date is{' '}
<strong>
{dateString(
holidayStopResponse.productSpecifics
.firstAvailableDate,
'cccc d MMMM',
)}
</strong>{' '}
due to{' '}
{productType.holidayStops.alternateNoticeString ? (
<strong>
{productType.holidayStops.alternateNoticeString}{' '}
period
</strong>
) : (
'our printing and delivery schedule (notice period)'
)}
.
<br />
{creditExplainerSentence(
productType.holidayStops.issueKeyword,
)}
</p>
<div css={oneAtATimeStyles}>
<div
css={css`
margin: 10px;
`}
>
<InfoIcon />
You can schedule one suspension at a time.
</div>
<div
css={css`
${from.mobileLandscape} {
display: none;
}
`}
>
<HolidayQuestionsModal
annualIssueLimit={
holidayStopResponse.annualIssueLimit
}
holidayStopFlowProperties={
productType.holidayStops
}
/>
</div>
</div>
<DatePicker
firstAvailableDate={
holidayStopResponse.productSpecifics
.firstAvailableDate
}
issueDaysOfWeek={
holidayStopResponse.productSpecifics.issueDaysOfWeek
}
issueKeyword={startCase(
productType.holidayStops.issueKeyword,
)}
existingDates={holidayStopResponse.existing
.filter(isNotWithdrawn)
.filter(
(holidayStopRequest) =>
holidayStopRequest.id !==
existingHolidayStopToAmendId,
)
.map((hsr) => hsr.dateRange)}
amendableDateRange={
existingHolidayStopToAmend?.dateRange
}
selectedRange={selectedRange}
maybeLockedStartDate={extractMaybeLockedStartDate(
existingHolidayStopToAmend,
)}
selectionInfo={
<HolidaySelectionInfo
productType={productType}
renewalDate={anniversaryDate}
combinedIssuesImpactedPerYear={
combinedIssuesImpactedPerYear
}
annualIssueLimit={
holidayStopResponse.annualIssueLimit
}
publicationsImpacted={publicationsImpacted}
issuesImpactedPerYearBySelection={
issuesImpactedPerYearBySelection
}
validationErrorMessage={validationErrorMessage}
selectedRange={selectedRange}
/>
}
onChange={onChange(
anniversaryDate,
productDetail.subscription.subscriptionId,
combinedIssuesImpactedPerYear,
allIssuesImpactedPerYear,
holidayStopResponse.annualIssueLimit,
productDetail.isTestUser,
)}
dateToAsterisk={anniversaryDate}
/>
<div
css={[
buttonBarCss,
css`
justify-content: flex-end;
`,
fixedButtonFooterCss,
]}
>
<div
css={css`
margin-right: 30px;
${until.mobileLandscape} {
display: none;
}
`}
>
<HolidayQuestionsModal
annualIssueLimit={
holidayStopResponse.annualIssueLimit
}
holidayStopFlowProperties={
productType.holidayStops
}
/>
</div>
<Link
css={css`
margin-right: ${space[5]}px;
${textSansBold17};
textdecoration: underline;
color: ${palette.neutral[20]};
`}
to=".."
state={routerState}
>
Cancel
</Link>
<div>
<Button
onClick={() => {
const readyForReview =
!validationErrorMessage &&
selectedRange &&
issuesImpactedPerYearBySelection;
if (readyForReview) {
navigate('../review', {
state: routerState,
});
} else {
setShowReviewWarning(true);
}
}}
>
Review details
</Button>
</div>
</div>
<div
css={css`
margin-top: ${space[5]}px;
display: flex;
justify-content: flex-end;
`}
>
{showReviewWarning && (
<InlineError>
Your request is incomplete. Please ensure your
chosen dates are valid and that your remaining
holiday balance has been calculated before
trying again.
</InlineError>
)}
</div>
</>
);
}
return <Navigate to=".." state={routerState} />;
}
return <GenericErrorScreen loggingMessage="No holiday stop response" />;
};