client/components/mma/delivery/records/DeliveryAddressStep.tsx (433 lines of code) (raw):
import { css } from '@emotion/react';
import {
from,
palette,
space,
textSans15,
textSans17,
until,
} from '@guardian/source/foundations';
import {
Button,
Checkbox,
CheckboxGroup,
} from '@guardian/source/react-components';
import Color from 'color';
import type { ChangeEvent, Dispatch, FormEvent, SetStateAction } from 'react';
import { useContext, useState } from 'react';
import { dateString } from '../../../../../shared/dates';
import type {
DeliveryAddress,
MembersDataApiResponse,
ProductDetail,
} from '../../../../../shared/productResponse';
import {
getSpecificProductType,
isProduct,
MembersDataApiAsyncLoader,
} from '../../../../../shared/productResponse';
import {
GROUPED_PRODUCT_TYPES,
PRODUCT_TYPES,
} from '../../../../../shared/productTypes';
import {
addressChangeAffectedInfo,
getValidDeliveryAddressChangeEffectiveDates,
} from '../../../../utilities/deliveryAddress';
import { createProductDetailFetcher } from '../../../../utilities/productUtils';
import { flattenEquivalent } from '../../../../utilities/utils';
import { CallCentreEmailAndNumbers } from '../../../shared/CallCenterEmailAndNumbers';
import { Input } from '../../../shared/Input';
import { COUNTRIES } from '../../identity/models';
import { InfoIconDark } from '../../shared/assets/InfoIconDark';
import { AsyncLoader } from '../../shared/AsyncLoader';
import { CallCentrePrompt } from '../../shared/CallCentrePrompt';
import { InfoSection } from '../../shared/InfoSection';
import { ProductDescriptionListTable } from '../../shared/ProductDescriptionListTable';
import type { ProductDescriptionListKeyValue } from '../../shared/ProductDescriptionListTable';
import { updateAddressFetcher } from '../address/deliveryAddressApi';
import { SuccessMessage } from '../address/DeliveryAddressConfirmation';
import type { SubscriptionEffectiveData } from '../address/DeliveryAddressFormContext';
import { convertToDescriptionListData } from '../address/DeliveryAddressFormContext';
import type { FormValidationResponse } from '../address/formValidation';
import { isFormValid } from '../address/formValidation';
import { Select } from '../address/Select';
import { DeliveryRecordsAddressContext } from './DeliveryRecordsProblemContext';
import { ReadOnlyAddressDisplay } from './ReadOnlyAddressDisplay';
interface DeliveryAddressStepProps {
productDetail: ProductDetail;
enableDeliveryInstructions: boolean;
setAddressValidationState: Dispatch<SetStateAction<boolean>>;
}
export const DeliveryAddressStep = (props: DeliveryAddressStepProps) => {
enum Status {
ReadOnly,
Edit,
ValidationError,
Pending,
Confirmation,
Error,
}
const [status, setStatus] = useState(Status.ReadOnly);
const deliveryAddressContext = useContext(DeliveryRecordsAddressContext);
const newAddress: DeliveryAddress =
deliveryAddressContext.address ||
(props.productDetail.subscription.deliveryAddress as DeliveryAddress);
const [
instructionsRemainingCharacters,
setInstructionsRemainingCharacters,
] = useState<number>(250 - (newAddress.instructions?.length || 0));
const [acknowledgementChecked, setAcknowledgementState] =
useState<boolean>(false);
const [formErrors, setFormErrors] = useState<FormValidationResponse>({
isValid: false,
});
const specificProductType = getSpecificProductType(
props.productDetail.tier,
);
const isNationalDelivery =
specificProductType === PRODUCT_TYPES.nationaldelivery;
const [showCallCentreNumbers, setCallCentreNumbersVisibility] =
useState<boolean>(false);
const [addressChangeInformation, setAddressChangeInformation] =
useState<string>('');
const handleFormSubmit =
(
subscriptionsNames: string[],
productsAffected: ProductDescriptionListKeyValue[],
addressChangeAffectedInfoArray: SubscriptionEffectiveData[],
) =>
(e: FormEvent) => {
e.preventDefault();
deliveryAddressContext.setProductsAffected?.(productsAffected);
setStatus(Status.Pending);
const isFormValidResponse = isFormValid(
newAddress,
subscriptionsNames,
);
setFormErrors({
addressLine1: isFormValidResponse.addressLine1,
town: isFormValidResponse.town,
postcode: isFormValidResponse.postcode,
country: isFormValidResponse.country,
} as FormValidationResponse);
if (isFormValidResponse.isValid && acknowledgementChecked) {
props.setAddressValidationState(true);
setAddressChangeInformation(
[
...addressChangeAffectedInfoArray.map(
(element) =>
`${element.friendlyProductName} subscription (${
element.subscriptionId
})${
element.effectiveDate
? ` as of front cover dated ${dateString(
element.effectiveDate,
'EEEE do MMMM yyyy',
)}`
: ''
}`,
),
'',
`(as displayed on confirmation page at ${dateString(
new Date(),
"HH:mm:ss x 'on' do MMMM yyyy",
)})`,
].join('\n'),
);
setStatus(Status.Confirmation);
} else {
setStatus(Status.ValidationError);
}
};
const renderDeliveryAddressForm = (
mdapiResponse: MembersDataApiResponse,
) => {
const contactIdToArrayOfProductDetailAndProductType =
getValidDeliveryAddressChangeEffectiveDates(
mdapiResponse.products
.filter(isProduct)
.filter(
(product) => product.subscription.readerType !== 'Gift',
),
);
const addressChangeAffectedInfoArray = addressChangeAffectedInfo(
contactIdToArrayOfProductDetailAndProductType,
);
const productsAffected: ProductDescriptionListKeyValue[] =
convertToDescriptionListData(addressChangeAffectedInfoArray);
const subscriptionNames = Object.values(
contactIdToArrayOfProductDetailAndProductType,
)
.flatMap(flattenEquivalent)
.map(({ productDetail }) => {
const specificProductType = getSpecificProductType(
productDetail.tier,
);
const friendlyProductName = specificProductType.friendlyName;
return `${friendlyProductName}`;
});
const hasNationalDelivery = Object.values(
contactIdToArrayOfProductDetailAndProductType,
)
.flatMap(flattenEquivalent)
.some(({ productType }) => {
return productType.productType === 'nationaldelivery';
});
const deliveryInstructionsInfoCss = css`
margin-top: ${space[3]}px;
${from.wide} {
display: inline-block;
margin: ${space[1]}px 0 ${space[3]}px ${space[3]}px;
width: calc(100% - (30ch + ${space[3]}px + 2px));
}
`;
if (hasNationalDelivery) {
return (
<div
css={css`
margin-top: ${space[3]}px;
`}
>
<CallCentrePrompt />
</div>
);
}
return (
<>
{productsAffected.length > 1 && (
<InfoSection>
Please note that changing your address here will update
the delivery address for all of your subscriptions.
</InfoSection>
)}
<form
action="#"
onSubmit={handleFormSubmit(
subscriptionNames,
productsAffected,
addressChangeAffectedInfoArray,
)}
>
<fieldset
css={css`
margin: 0;
padding: 0;
border: 0;
label {
margin-top: ${space[3]}px;
}
`}
>
<Input
label={'Address line 1'}
width={30}
value={newAddress.addressLine1}
changeSetState={(value: string) =>
deliveryAddressContext.setAddress?.({
...newAddress,
addressLine1: value,
})
}
inErrorState={
status === Status.ValidationError &&
!formErrors.addressLine1?.isValid
}
errorMessage={formErrors.addressLine1?.message}
/>
<Input
label="Address line 2"
width={30}
value={newAddress.addressLine2 || ''}
changeSetState={(value: string) =>
deliveryAddressContext.setAddress?.({
...newAddress,
addressLine2: value,
})
}
optional={true}
/>
<Input
label="Town or City"
width={30}
value={newAddress.town || ''}
changeSetState={(value: string) =>
deliveryAddressContext.setAddress?.({
...newAddress,
town: value,
})
}
inErrorState={
status === Status.ValidationError &&
!formErrors.town?.isValid
}
errorMessage={formErrors.town?.message}
/>
<Input
label="County or State"
width={30}
value={newAddress.region || ''}
optional={true}
changeSetState={(value: string) =>
deliveryAddressContext.setAddress?.({
...newAddress,
region: value,
})
}
/>
<Input
label="Postcode/Zipcode"
width={11}
value={newAddress.postcode}
changeSetState={(value: string) =>
deliveryAddressContext.setAddress?.({
...newAddress,
postcode: value,
})
}
inErrorState={
status === Status.ValidationError &&
!formErrors.postcode?.isValid
}
errorMessage={formErrors.postcode?.message}
/>
<Select
label={'Country'}
options={COUNTRIES.map((country) => {
return {
name: country.name,
value: country.name,
};
})}
width={30}
additionalCSS={css`
margin-top: 14px;
`}
value={
COUNTRIES.find(
(country) =>
newAddress.country === country.iso,
)?.name || newAddress.country
}
changeSetState={(value: string) =>
deliveryAddressContext.setAddress?.({
...newAddress,
country: value,
})
}
inErrorState={
status === Status.ValidationError &&
!formErrors.country?.isValid
}
errorMessage={formErrors.country?.message}
/>
{props.enableDeliveryInstructions && (
<label
css={css`
display: block;
color: ${palette.neutral['7']};
${textSans17};
font-weight: bold;
`}
>
Instructions
<div>
<div
css={css`
display: inline-block;
vertical-align: top;
margin-top: 4px;
width: 100%;
max-width: 30ch;
`}
>
<textarea
id="delivery-instructions"
name="instructions"
rows={2}
maxLength={250}
value={newAddress.instructions}
onChange={(
e: ChangeEvent<HTMLTextAreaElement>,
) => {
deliveryAddressContext.setAddress?.(
{
...newAddress,
instructions:
e.target.value,
},
);
setInstructionsRemainingCharacters(
250 - e.target.value.length,
);
}}
css={css`
width: 100%;
border: 2px solid
${palette.neutral['60']};
padding: 12px;
resize: vertical;
${textSans17};
`}
/>
<span
css={css`
display: block;
text-align: right;
${textSans15};
color: ${palette.neutral[46]};
`}
>
{instructionsRemainingCharacters}{' '}
characters remaining
</span>
</div>
<InfoSection
additionalCSS={
deliveryInstructionsInfoCss
}
>
Delivery instructions are only
applicable for newspaper deliveries.
They do not apply to Guardian Weekly.
</InfoSection>
</div>
</label>
)}
</fieldset>
<CheckboxGroup
cssOverrides={css`
margin-top: ${space[5]}px;
`}
name="instructions-checkbox"
error={
status === Status.ValidationError &&
!acknowledgementChecked
? 'Please indicate that you understand which subscriptions this change will affect.'
: undefined
}
>
<Checkbox
value="acknowledged"
label="I understand that this address change will affect the following subscriptions"
checked={acknowledgementChecked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setAcknowledgementState(e.target.checked);
}}
/>
</CheckboxGroup>
{productsAffected.length && (
<ProductDescriptionListTable
content={productsAffected}
seperateEachRow
/>
)}
<div
css={css`
* {
display: inline-block;
}
`}
>
<Button
type="submit"
cssOverrides={css`
color: ${palette.brand[400]};
background-color: ${palette.brand[800]};
:hover {
background-color: ${Color(
palette.brand[800],
'hex',
)
.darken(0.1)
.string()};
}
`}
>
Save address
</Button>
<Button
onClick={() => {
deliveryAddressContext.setAddress?.(
props.productDetail.subscription
.deliveryAddress,
);
props.setAddressValidationState(true);
setStatus(Status.ReadOnly);
}}
cssOverrides={css`
${until.mobileLandscape} {
margin-top: ${space[5]}px;
}
color: ${palette.brand[400]};
background-color: transparent;
:hover {
background-color: transparent;
}
`}
>
Discard changes
</Button>
</div>
</form>
{productsAffected.length > 1 && (
<p
css={css`
${textSans17};
background-color: ${palette.neutral[97]};
padding: ${space[5]}px ${space[5]}px ${space[5]}px
49px;
margin: ${space[5]}px 0 ${space[3]}px;
position: relative;
`}
>
<i
css={css`
width: 17px;
height: 17px;
position: absolute;
top: ${space[5]}px;
left: ${space[5]}px;
`}
>
<InfoIconDark fillColor={palette.brand[500]} />
</i>
If you need seperate delivery addresses for each of your
subscriptions, please{' '}
<span
css={css`
cursor: pointer;
color: ${palette.brand[500]};
text-decoration: underline;
`}
onClick={() =>
setCallCentreNumbersVisibility(
!showCallCentreNumbers,
)
}
>
contact us
</span>
.
</p>
)}
{showCallCentreNumbers && <CallCentreEmailAndNumbers />}
</>
);
};
const renderConfirmation = () => (
<>
<div
css={css`
padding: ${space[3]}px;
${from.tablet} {
padding: ${space[5]}px;
}
`}
>
<SuccessMessage
additionalCss={css`
margin-bottom: 0;
`}
message={`We have successfully updated your delivery details for your subscription${
deliveryAddressContext.productsAffected &&
deliveryAddressContext.productsAffected.length > 1 &&
's'
}. You will shortly receive a confirmation email.`}
/>
</div>
<ReadOnlyAddressDisplay
address={newAddress}
instructions={
(props.enableDeliveryInstructions &&
deliveryAddressContext.address?.instructions) ||
undefined
}
/>
</>
);
if (
status === Status.Edit ||
status === Status.Pending ||
status === Status.ValidationError
) {
return (
<div
css={css`
padding: ${space[3]}px;
${from.tablet} {
padding: ${space[5]}px;
}
`}
>
<MembersDataApiAsyncLoader
render={renderDeliveryAddressForm}
fetch={createProductDetailFetcher(
GROUPED_PRODUCT_TYPES.subscriptions
.allProductsProductTypeFilterString,
)}
loadingMessage={'Loading delivery details...'}
/>
</div>
);
} else if (
status === Status.Confirmation &&
props.productDetail.subscription.contactId
) {
return (
<AsyncLoader
render={renderConfirmation}
fetch={updateAddressFetcher(
{
...newAddress,
addressChangeInformation,
},
props.productDetail.subscription.contactId,
)}
readerOnOK={(resp: Response) => resp.text()}
loadingMessage={'Updating delivery address details...'}
/>
);
}
return (
<>
<ReadOnlyAddressDisplay
showEditButton={!isNationalDelivery}
editButtonCallback={() => {
props.setAddressValidationState(false);
setStatus(Status.Edit);
}}
address={newAddress}
instructions={
(props.enableDeliveryInstructions &&
deliveryAddressContext.address?.instructions) ||
undefined
}
promptIfInstructionsNotSet={
props.enableDeliveryInstructions || undefined
}
/>
{isNationalDelivery && (
<div
css={css`
padding: ${space[2]}px;
`}
>
<CallCentrePrompt />
</div>
)}
</>
);
};