client/components/helpCentre/contactUs/contactUsForm.tsx (464 lines of code) (raw):

import type { SerializedStyles } from '@emotion/react'; import { css } from '@emotion/react'; import { from, palette, space, textSans15, textSans17, textSansBold17, } from '@guardian/source/foundations'; import { Button } from '@guardian/source/react-components'; import type { ChangeEvent, FormEvent } from 'react'; import { useEffect, useState } from 'react'; import type { ContactUsFormPayload } from '../../../../shared/contactUsTypes'; import { base64FromFile, MAX_FILE_ATTACHMENT_SIZE_KB, VALID_IMAGE_FILE_EXTENSIONS, VALID_IMAGE_FILE_MIME_TYPES, } from '../../../../shared/fileUploadUtils'; import { isEmail } from '../../../../shared/validationUtils'; import type { Grecaptcha } from '../../../utilities/captcha'; import { ErrorIcon } from '../../mma/shared/assets/ErrorIcon'; import { CallCentreEmailAndNumbers } from '../../shared/CallCenterEmailAndNumbers'; import { FormError } from '../../shared/FormError'; import { Input } from '../../shared/Input'; import { Spinner } from '../../shared/Spinner'; import { UploadFileInput } from './UploadFileInput'; interface ContactUsFormProps { submitCallback: (payload: ContactUsFormPayload) => Promise<boolean>; title: string; subject: string; editableSubject?: boolean; additionalCss?: SerializedStyles; } interface FormElemValidationObject { isValid: boolean; errorMessage: string; } interface FormValidationState { inValidationMode: boolean; name: FormElemValidationObject; email: FormElemValidationObject; subject: FormElemValidationObject; message: FormElemValidationObject; captcha: FormElemValidationObject; fileAttachment: FormElemValidationObject; } type ContactUsFormStatus = 'form' | 'submitting' | 'failure'; declare const window: Window & { grecaptcha: Grecaptcha; v2ReCaptchaOnLoadCallback: () => void; }; export const ContactUsForm = (props: ContactUsFormProps) => { const [captchaToken, setCaptchaToken] = useState<string>(''); const [subject, setSubject] = useState<string>(props.subject); const [name, setName] = useState<string>( (typeof window !== 'undefined' && window?.guardian?.identityDetails?.displayName) || '', ); const [email, setEmail] = useState<string>( (typeof window !== 'undefined' && window.guardian?.identityDetails?.email) || '', ); const [message, setMessage] = useState<string>(''); const [messageRemainingCharacters, setMessageRemainingCharacters] = useState<number>(2500); const [fileAttachment, setFileAttachment] = useState<File | undefined>(); const [status, setStatus] = useState<ContactUsFormStatus>('form'); const [showCustomerServiceInfo, setShowCustomerServiceInfo] = useState<boolean>(false); const mandatoryFieldMessage = 'You cannot leave this field empty'; const [formValidationState, setFormValidationState] = useState<FormValidationState>({ inValidationMode: false, name: { isValid: true, errorMessage: mandatoryFieldMessage, }, email: { isValid: true, errorMessage: 'Please insert a valid email address.', }, subject: { isValid: true, errorMessage: mandatoryFieldMessage, }, message: { isValid: true, errorMessage: mandatoryFieldMessage, }, captcha: { isValid: !!captchaToken.length, errorMessage: 'Please confirm you are not a robot', }, fileAttachment: { isValid: true, errorMessage: 'There is a maximum file size limit of 5mb', }, }); const validateForm = () => { const isNameValid = !!name.length; const isEmailValid = isEmail(email); const isSubjectValid = !!subject.length; const isDetailsValid = !!message.length; const isFileAttachmentValid = fileAttachment === undefined || fileAttachment.size / 1024 <= MAX_FILE_ATTACHMENT_SIZE_KB; const isFormInValidState = isNameValid && isEmailValid && isSubjectValid && isDetailsValid && !!captchaToken.length && isFileAttachmentValid; setFormValidationState({ ...formValidationState, inValidationMode: !isFormInValidState, name: { ...formValidationState.name, isValid: isNameValid }, email: { ...formValidationState.email, isValid: isEmailValid }, subject: { ...formValidationState.subject, isValid: isSubjectValid, }, message: { ...formValidationState.message, isValid: isDetailsValid, }, fileAttachment: { ...formValidationState.fileAttachment, isValid: isFileAttachmentValid, }, captcha: { ...formValidationState.captcha, isValid: !!captchaToken.length, }, }); return isFormInValidState; }; useEffect(() => { if (window.grecaptcha) { renderReCaptcha(); } else { const script = document.createElement('script'); script.setAttribute( 'src', 'https://www.google.com/recaptcha/api.js?onload=v2ReCaptchaOnLoadCallback&render=explicit', ); // tslint:disable-next-line:no-object-mutation window.v2ReCaptchaOnLoadCallback = renderReCaptcha; document.head.appendChild(script); } }, []); useEffect(() => { if (!formValidationState.fileAttachment.isValid) { validateForm(); } }); const renderReCaptcha = () => { window.grecaptcha.render('recaptcha', { sitekey: window.guardian?.recaptchaPublicKey, callback: (token: string) => setCaptchaToken(token), }); }; return ( <form onSubmit={async (event: FormEvent) => { event.preventDefault(); if (validateForm()) { setStatus('submitting'); props .submitCallback({ name, subject, email, message, captchaToken, attachment: fileAttachment && { name: fileAttachment.name, contents: (await base64FromFile( fileAttachment, )) as string, }, }) .then((success) => { if (!success) { setStatus('failure'); } }); } }} css={css` margin-top: ${space[9]}px; `} > <fieldset onChange={() => { if (formValidationState.inValidationMode) { validateForm(); } if (status === 'failure') { setStatus('form'); } }} css={css` border: 1px solid ${palette.neutral[86]}; margin: 0 0 ${space[5]}px; padding: 0; `} > <legend css={css` display: block; width: 100%; margin: 0; padding: ${space[3]}px; float: left; background-color: ${palette.neutral[97]}; border-bottom: 1px solid ${palette.neutral[86]}; ${textSansBold17}; ${from.tablet} { padding: ${space[3]}px ${space[5]}px; } `} > {props.title} </legend> <p css={css` ${textSans17}; margin: ${space[5]}px; :before { display: block; content: ''; clear: both; padding-top: ${space[5]}px; } `} > Let us know the details of what you’d like to discuss and we will aim to get back to you as soon as possible. Please note if you are contacting us regarding an account you hold with us you will need to use the email you registered with. </p> <Input label="Full Name" width={50} changeSetState={(newName) => setName(newName.substring(0, 50)) } value={name} additionalCss={css` margin: ${space[5]}px; `} inErrorState={ formValidationState.inValidationMode && !formValidationState.name.isValid } errorMessage={formValidationState.name.errorMessage} /> <Input label="Email address" secondaryLabel="If you are contacting us regarding an account you hold with us you must use the email you registered with" width={50} changeSetState={(newEmail) => setEmail(newEmail.substring(0, 50)) } value={email} additionalCss={css` margin: ${space[5]}px; `} inErrorState={ formValidationState.inValidationMode && !formValidationState.email.isValid } errorMessage={formValidationState.email.errorMessage} /> {props.editableSubject ? ( <Input label="Subject of enquiry" type="text" width={50} changeSetState={(newSubject) => setSubject(newSubject.substring(0, 100)) } value={subject} additionalCss={css` margin: ${space[5]}px; `} inErrorState={ formValidationState.inValidationMode && !formValidationState.subject.isValid } errorMessage={formValidationState.subject.errorMessage} /> ) : ( <label css={css` display: block; color: ${palette.neutral[7]}; ${textSansBold17}; max-width: 50ch; margin: ${space[5]}px; `} > Subject of enquiry <span css={css` display: block; font-weight: normal; `} > {subject} </span> </label> )} <label css={css` display: block; color: ${palette.neutral[7]}; ${textSansBold17}; max-width: 50ch; margin: ${space[5]}px; `} > Problem details {formValidationState.inValidationMode && !formValidationState.message.isValid && ( <span css={css` display: block; color: ${palette.error[400]}; font-weight: normal; `} > <i css={css` margin-right: 4px; `} > <ErrorIcon /> </i> {formValidationState.message.errorMessage} </span> )} <textarea id="contact-us-message" name="message" rows={2} maxLength={2500} value={message} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { setMessage(e.target.value); setMessageRemainingCharacters( 2500 - e.target.value.length, ); }} css={css` width: 100%; border: ${formValidationState.inValidationMode && !formValidationState.message.isValid ? `4px solid ${palette.error[400]}` : `2px solid ${palette.neutral[60]}`}; padding: 12px; resize: vertical; ${textSans17}; `} /> <span css={css` display: block; text-align: right; ${textSans15}; color: ${palette.neutral[46]}; `} > {messageRemainingCharacters} characters remaining </span> </label> <UploadFileInput title="Upload image" optional description={`File must be in ${VALID_IMAGE_FILE_EXTENSIONS.join( ', ', ).replace(/,(?=[^,]*$)/, ' or ')} format and less than 5MB`} allowedFileFormats={VALID_IMAGE_FILE_MIME_TYPES} changeSetState={setFileAttachment} inErrorState={ formValidationState.inValidationMode && !formValidationState.fileAttachment.isValid } errorMessage={ formValidationState.fileAttachment.errorMessage } additionalCss={css` margin: ${space[5]}px; `} /> </fieldset> {status === 'failure' && ( <FormError title="Something went wrong when submitting your form" messages={[ <> Please try again or if the problem persists please contact{' '} <Button priority="subdued" cssOverrides={css` font-weight: normal; text-decoration: underline; `} onClick={() => setShowCustomerServiceInfo(true)} > Customer Service </Button> </>, ]} /> )} {showCustomerServiceInfo && <CallCentreEmailAndNumbers />} <div css={css` margin: ${space[5]}px 0; `} > {formValidationState.inValidationMode && !formValidationState.captcha.isValid && ( <span css={css` display: block; color: ${palette.error[400]}; ${textSans17}; `} > <i css={css` margin-right: 4px; `} > <ErrorIcon /> </i> {formValidationState.captcha.errorMessage} </span> )} <div id="recaptcha" /> </div> <Button type="submit" iconSide="right" icon={ status === 'submitting' ? ( <Spinner scale={0.5} /> ) : undefined } disabled={status === 'submitting'} > Submit </Button> </form> ); };