client/components/mma/delivery/address/DeliveryAddressForm.tsx (358 lines of code) (raw):
import { css } from '@emotion/react';
import {
from,
headlineBold28,
palette,
space,
textSans15,
textSans17,
textSansBold17,
until,
} from '@guardian/source/foundations';
import {
Button,
Checkbox,
CheckboxGroup,
Stack,
} from '@guardian/source/react-components';
import type { ChangeEvent, Dispatch, FormEvent, SetStateAction } from 'react';
import { useContext, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { addressChangeAffectedInfo } from '@/client/utilities/deliveryAddress';
import { flattenEquivalent } from '@/client/utilities/utils';
import type { DeliveryAddress } from '@/shared/productResponse';
import { getSpecificProductType } from '@/shared/productResponse';
import type { ProductType, WithProductType } from '@/shared/productTypes';
import { CallCentreEmailAndNumbers } from '../../../shared/CallCenterEmailAndNumbers';
import { CallCentreNumbers } from '../../../shared/CallCentreNumbers';
import { Input } from '../../../shared/Input';
import { NAV_LINKS } from '../../../shared/nav/NavConfig';
import { COUNTRIES } from '../../identity/models';
import { InfoIconDark } from '../../shared/assets/InfoIconDark';
import { CallCentrePrompt } from '../../shared/CallCentrePrompt';
import { InfoSection } from '../../shared/InfoSection';
import type { ProductDescriptionListKeyValue } from '../../shared/ProductDescriptionListTable';
import { ProductDescriptionListTable } from '../../shared/ProductDescriptionListTable';
import { ProgressIndicator } from '../../shared/ProgressIndicator';
import type { AddressSetStateObject } from './DeliveryAddressFormContext';
import {
ContactIdContext,
convertToDescriptionListData,
NewDeliveryAddressContext,
} from './DeliveryAddressFormContext';
import type { FormValidationResponse } from './formValidation';
import { isFormValid } from './formValidation';
import { Select } from './Select';
interface FormStates {
INIT: string;
PENDING: string;
VALIDATION_ERROR: string;
VALIDATION_SUCCESS: string;
SUCCESS: string;
POST_ERROR: string;
}
const formStates: FormStates = {
INIT: 'init',
PENDING: 'pending',
VALIDATION_ERROR: 'validationError',
VALIDATION_SUCCESS: 'validationSuccess',
SUCCESS: 'success',
POST_ERROR: 'postError',
};
interface FormProps {
formStatus: string;
setFormStatus: Dispatch<SetStateAction<string>>;
formErrors: FormValidationResponse;
setFormErrors: Dispatch<SetStateAction<FormValidationResponse>>;
warning?: ProductDescriptionListKeyValue[];
productType: ProductType;
}
const Form = (props: FormProps) => {
const location = useLocation();
const navigate = useNavigate();
const addressStateContext = useContext(NewDeliveryAddressContext);
const contactIdToArrayOfProductDetailAndProductType =
useContext(ContactIdContext);
const addressStateObject =
addressStateContext.addressStateObject as DeliveryAddress;
const addressSetStateObject =
addressStateContext.addressSetStateObject as AddressSetStateObject;
const [showTopCallCentreNumbers, setTopCallCentreNumbersVisibility] =
useState<boolean>(false);
const [
instructionsRemainingCharacters,
setInstructionsRemainingCharacters,
] = useState<number>(250 - (addressStateObject.instructions?.length || 0));
const [acknowledgementChecked, setAcknowledgementState] =
useState<boolean>(false);
const subscriptionsNames = Object.values(
contactIdToArrayOfProductDetailAndProductType,
)
.flatMap(flattenEquivalent)
.map(({ productDetail }) => {
const specificProductType = getSpecificProductType(
productDetail.tier,
);
const friendlyProductName = specificProductType.friendlyName;
return `${friendlyProductName}`;
});
const handleFormSubmit = (e: FormEvent) => {
e.preventDefault();
props.setFormStatus(formStates.PENDING);
const formData: DeliveryAddress = {
addressLine1: addressStateObject.addressLine1,
addressLine2: addressStateObject.addressLine2,
town: addressStateObject.town,
region: addressStateObject.region,
postcode: addressStateObject.postcode,
country: addressStateObject.country,
};
const isFormValidResponse = isFormValid(formData, subscriptionsNames);
props.setFormErrors({
addressLine1: isFormValidResponse.addressLine1,
town: isFormValidResponse.town,
postcode: isFormValidResponse.postcode,
country: isFormValidResponse.country,
} as FormValidationResponse);
if (isFormValidResponse.isValid && acknowledgementChecked) {
navigate('review', { state: location.state });
} else {
props.setFormStatus(formStates.VALIDATION_ERROR);
}
};
return (
<>
<form action="#" onSubmit={handleFormSubmit}>
<fieldset
css={{
border: `1px solid ${palette.neutral['86']}`,
padding: '48px 14px 14px',
position: 'relative',
marginBottom: `${space[5]}px`,
label: {
marginTop: `${space[3]}px`,
},
}}
>
<legend
css={css`
width: 100%;
position: absolute;
top: 0;
left: 0;
padding: 0 14px;
${textSans17};
font-weight: bold;
line-height: 48px;
background-color: ${palette.neutral['97']};
border-bottom: 1px solid ${palette.neutral['86']};
`}
>
Delivery address
{props.productType.delivery
?.enableDeliveryInstructionsUpdate &&
' and instructions'}
</legend>
<Input
label={'Address line 1'}
width={30}
value={addressStateObject.addressLine1}
changeSetState={addressSetStateObject.setAddressLine1}
inErrorState={
props.formStatus === formStates.VALIDATION_ERROR &&
!props.formErrors.addressLine1?.isValid
}
errorMessage={props.formErrors.addressLine1?.message}
/>
<Input
label="Address line 2"
width={30}
value={addressStateObject.addressLine2 || ''}
changeSetState={addressSetStateObject.setAddressLine2}
optional={true}
/>
<Input
label="Town or City"
width={30}
value={addressStateObject.town || ''}
changeSetState={addressSetStateObject.setTown}
inErrorState={
props.formStatus === formStates.VALIDATION_ERROR &&
!props.formErrors.town?.isValid
}
errorMessage={props.formErrors.town?.message}
/>
<Input
label="County or State"
width={30}
value={addressStateObject.region || ''}
optional={true}
changeSetState={addressSetStateObject.setRegion}
/>
<Input
label="Postcode/Zipcode"
width={11}
value={addressStateObject.postcode}
changeSetState={addressSetStateObject.setPostcode}
inErrorState={
props.formStatus === formStates.VALIDATION_ERROR &&
!props.formErrors.postcode?.isValid
}
errorMessage={props.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={addressStateObject.country}
changeSetState={addressSetStateObject.setCountry}
inErrorState={
props.formStatus === formStates.VALIDATION_ERROR &&
!props.formErrors.country?.isValid
}
errorMessage={props.formErrors.country?.message}
/>
{props.productType.delivery
?.enableDeliveryInstructionsUpdate && (
<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={addressStateObject.instructions}
onChange={(
e: ChangeEvent<HTMLTextAreaElement>,
) => {
addressSetStateObject.setInstructions(
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>
<p
css={css`
display: block;
${textSans17};
border: 4px solid ${palette.brand[500]};
padding: ${space[5]}px ${space[5]}px
${space[5]}px 49px;
margin: ${space[3]}px 0;
position: relative;
${from.tablet} {
display: inline-block;
vertical-align: top;
margin: 2px 0 ${space[3]}px
${space[3]}px;
width: calc(
100% -
(30ch + ${space[3]}px + 2px)
);
}
`}
>
<i
css={css`
width: 17px;
height: 17px;
position: absolute;
top: ${space[5]}px;
left: ${space[5]}px;
`}
>
<InfoIconDark
fillColor={palette.brand[500]}
/>
</i>
Delivery instructions are only applicable
for newspaper deliveries. They do not apply
to Guardian Weekly.
</p>
</div>
</label>
)}
</fieldset>
<CheckboxGroup
name="instructions-checkbox"
error={
props.formStatus === formStates.VALIDATION_ERROR &&
!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>
{props.warning && (
<ProductDescriptionListTable
content={props.warning}
seperateEachRow
/>
)}
<div
css={css`
margin-top: ${space[5]}px;
* {
display: inline-block;
}
${from.tablet} {
margin-top: ${space[6]}px;
}
`}
>
<Button type="submit">Review details</Button>
<Link
to={NAV_LINKS.accountOverview.link}
css={css`
${textSansBold17};
margin-left: 22px;
color: ${palette.brand[400]};
`}
>
Cancel
</Link>
</div>
</form>
<Stack space={5}>
<p
css={css`
${textSans17};
margin: ${space[12]}px 0 0;
color: ${palette.neutral[46]};
`}
>
If you need separate delivery addresses for each of your
subscriptions, please{' '}
<span
css={css`
cursor: pointer;
color: ${palette.brand[500]};
text-decoration: underline;
`}
onClick={() =>
setTopCallCentreNumbersVisibility(
!showTopCallCentreNumbers,
)
}
>
contact us
</span>
.
</p>
{showTopCallCentreNumbers && <CallCentreEmailAndNumbers />}
</Stack>
</>
);
};
export const DeliveryAddressUpdate = (props: WithProductType<ProductType>) => {
const [formStatus, setFormStatus] = useState<string>(formStates.INIT);
const [formErrors, setFormErrors] = useState({ isValid: false });
const contactIdToArrayOfProductDetailAndProductType =
useContext(ContactIdContext);
const subHeadingCss = `
border-top: 1px solid ${palette.neutral['86']};
${headlineBold28};
margin-top: 50px;
${until.tablet} {
font-size: 1.25rem;
line-height: 1.6;
};
`;
const hasNationalDelivery = Object.values(
contactIdToArrayOfProductDetailAndProductType,
)
.flatMap(flattenEquivalent)
.some(({ productType }) => {
return productType.productType === 'nationaldelivery';
});
if (hasNationalDelivery) {
return (
<div
css={css`
margin-top: ${space[3]}px;
`}
>
<CallCentrePrompt />
</div>
);
}
return (
<>
<ProgressIndicator
steps={[
{ title: 'Update', isCurrentStep: true },
{ title: 'Review' },
{ title: 'Confirmation' },
]}
additionalCSS={css`
margin-top: ${space[5]}px;
`}
/>
<h2
css={css`
${subHeadingCss}
`}
>
Update address details
</h2>
{Object.keys(contactIdToArrayOfProductDetailAndProductType)
.length === 0 && (
<div>
<p>
No addresses available for update. If this doesn't seem
right please contact us
</p>
<CallCentreNumbers />
</div>
)}
{Object.keys(contactIdToArrayOfProductDetailAndProductType).length >
1 && (
<div>
<p>You will need to contact us to update your addresses</p>
<CallCentreNumbers />
</div>
)}
{Object.keys(contactIdToArrayOfProductDetailAndProductType)
.length === 1 && (
<div>
{Object.values(
contactIdToArrayOfProductDetailAndProductType,
).flatMap(flattenEquivalent).length > 1 && (
<InfoSection>
Please note that changing your address here will
update the delivery address for all of your
subscriptions.
</InfoSection>
)}
{(formStatus === formStates.INIT ||
formStatus === formStates.PENDING ||
formStatus === formStates.VALIDATION_ERROR) && (
<Form
formStatus={formStatus}
setFormStatus={setFormStatus}
formErrors={formErrors}
setFormErrors={setFormErrors}
productType={props.productType}
warning={convertToDescriptionListData(
addressChangeAffectedInfo(
contactIdToArrayOfProductDetailAndProductType,
),
)}
/>
)}
</div>
)}
</>
);
};