diff --git a/app/assets/images/idv/capture-tips-clean.svg b/app/assets/images/idv/capture-tips-clean.svg new file mode 100644 index 00000000000..60b0eccdbf9 --- /dev/null +++ b/app/assets/images/idv/capture-tips-clean.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/idv/capture-tips-lighting.svg b/app/assets/images/idv/capture-tips-lighting.svg new file mode 100644 index 00000000000..aba228f49c2 --- /dev/null +++ b/app/assets/images/idv/capture-tips-lighting.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/idv/capture-tips-surface.svg b/app/assets/images/idv/capture-tips-surface.svg new file mode 100644 index 00000000000..e752997bb88 --- /dev/null +++ b/app/assets/images/idv/capture-tips-surface.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/packages/document-capture/components/block-link.jsx b/app/javascript/packages/components/block-link.jsx similarity index 100% rename from app/javascript/packages/document-capture/components/block-link.jsx rename to app/javascript/packages/components/block-link.jsx diff --git a/spec/javascripts/packages/document-capture/components/block-link-spec.jsx b/app/javascript/packages/components/block-link.spec.jsx similarity index 85% rename from spec/javascripts/packages/document-capture/components/block-link-spec.jsx rename to app/javascript/packages/components/block-link.spec.jsx index 5d1821ba03b..b37dcb0617a 100644 --- a/spec/javascripts/packages/document-capture/components/block-link-spec.jsx +++ b/app/javascript/packages/components/block-link.spec.jsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react'; -import BlockLink from '@18f/identity-document-capture/components/block-link'; +import BlockLink from './block-link'; -describe('document-capture/components/block-link', () => { +describe('BlockLink', () => { const linkText = 'link text'; const url = '/example'; diff --git a/app/javascript/packages/components/index.js b/app/javascript/packages/components/index.js index f3b5d26a59d..cae3d0acae7 100644 --- a/app/javascript/packages/components/index.js +++ b/app/javascript/packages/components/index.js @@ -1,2 +1,4 @@ export { default as Alert } from './alert'; +export { default as BlockLink } from './block-link'; export { default as Icon } from './icon'; +export { default as TroubleshootingOptions } from './troubleshooting-options'; diff --git a/app/javascript/packages/components/troubleshooting-options.jsx b/app/javascript/packages/components/troubleshooting-options.jsx new file mode 100644 index 00000000000..f6ff90c53b1 --- /dev/null +++ b/app/javascript/packages/components/troubleshooting-options.jsx @@ -0,0 +1,38 @@ +import { BlockLink } from '@18f/identity-components'; + +/** + * @typedef TroubleshootingOption + * + * @prop {string} url + * @prop {string|JSX.Element} text + * @prop {boolean=} isExternal + */ + +/** + * @typedef TroubleshootingOptionsProps + * + * @prop {string} heading + * @prop {TroubleshootingOption[]} options + */ + +/** + * @param {TroubleshootingOptionsProps} props + */ +function TroubleshootingOptions({ heading, options }) { + return ( +
+

{heading}

+ +
+ ); +} + +export default TroubleshootingOptions; diff --git a/app/javascript/packages/components/troubleshooting-options.spec.jsx b/app/javascript/packages/components/troubleshooting-options.spec.jsx new file mode 100644 index 00000000000..adfefbc76b7 --- /dev/null +++ b/app/javascript/packages/components/troubleshooting-options.spec.jsx @@ -0,0 +1,34 @@ +import { render } from '@testing-library/react'; +import TroubleshootingOptions from './troubleshooting-options'; + +describe('TroubleshootingOptions', () => { + it('renders a given heading', () => { + const { getByRole } = render(); + + const heading = getByRole('heading'); + + expect(heading.textContent).to.equal('Need help?'); + }); + + it('renders given options', () => { + const { getAllByRole } = render( + Option 1, url: 'https://example.com/1', isExternal: true }, + { text: 'Option 2', url: 'https://example.com/2' }, + ]} + />, + ); + + const links = /** @type {HTMLAnchorElement[]} */ (getAllByRole('link')); + + expect(links).to.have.lengthOf(2); + expect(links[0].textContent).to.equal('Option 1 links.new_window'); + expect(links[0].href).to.equal('https://example.com/1'); + expect(links[0].target).to.equal('_blank'); + expect(links[1].textContent).to.equal('Option 2'); + expect(links[1].href).to.equal('https://example.com/2'); + expect(links[1].target).to.be.empty(); + }); +}); diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 43f6a51dbf4..08561a65d8a 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -10,6 +10,7 @@ import { import { useI18n } from '@18f/identity-react-i18n'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; +import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; import AcuantCaptureCanvas from './acuant-capture-canvas'; import FileInput from './file-input'; import FullScreen from './full-screen'; @@ -272,6 +273,9 @@ function AcuantCapture( const { isMobile } = useContext(DeviceContext); const { t, formatHTML } = useI18n(); const [attempt, incrementAttempt] = useCounter(1); + const { onFailedCaptureAttempt, onResetFailedCaptureAttempts } = useContext( + FailedCaptureAttemptsContext, + ); const hasCapture = !isError && (isReady ? isCameraSupported : isMobile); useEffect(() => { // If capture had started before Acuant was ready, stop capture if readiness reveals that no @@ -297,7 +301,7 @@ function AcuantCapture( /** * Returns an analytics payload, decorated with common values. * - * @template P + * @template {ImageAnalyticsPayload|AcuantImageAnalyticsPayload} P * * @param {P} payload * @@ -480,6 +484,9 @@ function AcuantCapture( if (assessment === 'success') { onChangeAndResetError(data, analyticsPayload); + onResetFailedCaptureAttempts(); + } else { + onFailedCaptureAttempt({ isAssessedAsGlare, isAssessedAsBlurry }); } setIsCapturingEnvironment(false); diff --git a/app/javascript/packages/document-capture/components/capture-advice.jsx b/app/javascript/packages/document-capture/components/capture-advice.jsx new file mode 100644 index 00000000000..b94e9d58800 --- /dev/null +++ b/app/javascript/packages/document-capture/components/capture-advice.jsx @@ -0,0 +1,90 @@ +import { useContext } from 'react'; +import { useI18n } from '@18f/identity-react-i18n'; +import ServiceProviderContext from '../context/service-provider'; +import MarketingSiteContext from '../context/marketing-site'; +import useAsset from '../hooks/use-asset'; +import Warning from './warning'; + +/** @typedef {import('@18f/identity-components/troubleshooting-options').TroubleshootingOption} TroubleshootingOption */ + +/** + * @typedef CaptureAdviceProps + * + * @prop {() => void} onTryAgain + * @prop {boolean} isAssessedAsGlare + * @prop {boolean} isAssessedAsBlurry + */ + +/** + * @param {CaptureAdviceProps} props + */ +function CaptureAdvice({ onTryAgain, isAssessedAsGlare, isAssessedAsBlurry }) { + const { name: spName, getFailureToProofURL } = useContext(ServiceProviderContext); + const { documentCaptureTipsURL } = useContext(MarketingSiteContext); + const { getAssetPath } = useAsset(); + const { t } = useI18n(); + + return ( + +

+ {isAssessedAsGlare && t('doc_auth.tips.capture_troubleshooting_glare')} + {isAssessedAsBlurry && t('doc_auth.tips.capture_troubleshooting_blurry')}{' '} + {t('doc_auth.tips.capture_troubleshooting_lead')} +

+
    +
  • + {t('doc_auth.tips.capture_troubleshooting_surface_image')} + {t('doc_auth.tips.capture_troubleshooting_surface')} +
  • +
  • + {t('doc_auth.tips.capture_troubleshooting_lighting_image')} + {t('doc_auth.tips.capture_troubleshooting_lighting')} +
  • +
  • + {t('doc_auth.tips.capture_troubleshooting_clean_image')} + {t('doc_auth.tips.capture_troubleshooting_clean')} +
  • +
+
+ ); +} + +export default CaptureAdvice; diff --git a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx new file mode 100644 index 00000000000..5eaa04890d2 --- /dev/null +++ b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx @@ -0,0 +1,34 @@ +import { useContext, useState } from 'react'; +import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; +import CaptureAdvice from './capture-advice'; + +/** @typedef {import('react').ReactNode} ReactNode */ + +/** + * @typedef CaptureTroubleshootingProps + * + * @prop {ReactNode} children + */ + +/** + * @param {CaptureTroubleshootingProps} props + */ +function CaptureTroubleshooting({ children }) { + const [didShowTroubleshooting, setDidShowTroubleshooting] = useState(false); + const { failedCaptureAttempts, maxFailedAttemptsBeforeTips, lastAttemptMetadata } = useContext( + FailedCaptureAttemptsContext, + ); + const { isAssessedAsGlare, isAssessedAsBlurry } = lastAttemptMetadata; + + return failedCaptureAttempts >= maxFailedAttemptsBeforeTips && !didShowTroubleshooting ? ( + setDidShowTroubleshooting(true)} + isAssessedAsGlare={isAssessedAsGlare} + isAssessedAsBlurry={isAssessedAsBlurry} + /> + ) : ( + <>{children} + ); +} + +export default CaptureTroubleshooting; diff --git a/app/javascript/packages/document-capture/components/document-capture.jsx b/app/javascript/packages/document-capture/components/document-capture.jsx index 26bdf01672a..58c839fcb2f 100644 --- a/app/javascript/packages/document-capture/components/document-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-capture.jsx @@ -9,7 +9,6 @@ import ReviewIssuesStep, { reviewIssuesStepValidator } from './review-issues-ste import ServiceProviderContext from '../context/service-provider'; import Submission from './submission'; import SubmissionStatus from './submission-status'; -import DesktopDocumentDisclosure from './desktop-document-disclosure'; import { RetrySubmissionError } from './submission-complete'; import { BackgroundEncryptedUploadError } from '../higher-order/with-background-encrypted-upload'; import SuspenseErrorBoundary from './suspense-error-boundary'; @@ -94,23 +93,18 @@ function DocumentCapture({ isAsyncForm = false, onStepChange }) { ? [ { name: 'review', - title: t('doc_auth.headings.review_issues'), form: ReviewIssuesStep, validator: reviewIssuesStepValidator, - footer: DesktopDocumentDisclosure, }, ] : /** @type {FormStep[]} */ ([ { name: 'documents', - title: t('doc_auth.headings.document_capture'), form: DocumentsStep, validator: documentsStepValidator, - footer: DesktopDocumentDisclosure, }, serviceProvider.isLivenessRequired && { name: 'selfie', - title: t('doc_auth.headings.selfie'), form: SelfieStep, validator: selfieStepValidator, }, diff --git a/app/javascript/packages/document-capture/components/documents-step.jsx b/app/javascript/packages/document-capture/components/documents-step.jsx index 2b1f5d635fa..ad0af23f809 100644 --- a/app/javascript/packages/document-capture/components/documents-step.jsx +++ b/app/javascript/packages/document-capture/components/documents-step.jsx @@ -1,10 +1,14 @@ import { useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import BlockLink from './block-link'; +import { BlockLink } from '@18f/identity-components'; +import { FormStepsContinueButton } from './form-steps'; import DocumentSideAcuantCapture from './document-side-acuant-capture'; import DeviceContext from '../context/device'; import ServiceProviderContext from '../context/service-provider'; import withBackgroundEncryptedUpload from '../higher-order/with-background-encrypted-upload'; +import DesktopDocumentDisclosure from './desktop-document-disclosure'; +import CaptureTroubleshooting from './capture-troubleshooting'; +import PageHeading from './page-heading'; /** * @typedef {'front'|'back'} DocumentSide @@ -48,7 +52,8 @@ function DocumentsStep({ const serviceProvider = useContext(ServiceProviderContext); return ( - <> + + {t('doc_auth.headings.document_capture')} {isMobile &&

{t('doc_auth.info.document_capture_intro_acknowledgment')}

}

{t('doc_auth.tips.document_capture_header_text')}

    @@ -75,7 +80,9 @@ function DocumentsStep({ onError={onError} /> ))} - + + + ); } diff --git a/app/javascript/packages/document-capture/components/form-steps.jsx b/app/javascript/packages/document-capture/components/form-steps.jsx index 40d06dbfba4..ad348dfb60f 100644 --- a/app/javascript/packages/document-capture/components/form-steps.jsx +++ b/app/javascript/packages/document-capture/components/form-steps.jsx @@ -1,14 +1,14 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, createContext, useContext } from 'react'; import { Alert } from '@18f/identity-components'; import { useI18n } from '@18f/identity-react-i18n'; import Button from './button'; -import PageHeading from './page-heading'; import FormErrorMessage, { RequiredValueMissingError } from './form-error-message'; import PromptOnNavigate from './prompt-on-navigate'; import useHistoryParam from '../hooks/use-history-param'; import useForceRender from '../hooks/use-force-render'; import useDidUpdateEffect from '../hooks/use-did-update-effect'; import useIfStillMounted from '../hooks/use-if-still-mounted'; +import './form-steps.scss'; /** * @typedef FormStepError @@ -53,9 +53,7 @@ import useIfStillMounted from '../hooks/use-if-still-mounted'; * @typedef FormStep * * @prop {string} name Step name, used in history parameter. - * @prop {string} title Step title, shown as heading. * @prop {import('react').FC>>} form Step form component. - * @prop {import('react').FC=} footer Optional step footer component. * @prop {(object)=>boolean=} validator Optional function to validate values for the step */ @@ -78,6 +76,20 @@ import useIfStillMounted from '../hooks/use-if-still-mounted'; * @prop {()=>void=} onStepChange Callback triggered on step change. */ +/** + * @typedef FormStepsContext + * + * @prop {boolean} isLastStep + * @prop {boolean} canContinueToNextStep + */ + +const FormStepsContext = createContext( + /** @type {FormStepsContext} */ ({ + isLastStep: true, + canContinueToNextStep: true, + }), +); + /** * Returns the index of the step in the array which matches the given name. Returns `-1` if there is * no step found by that name. @@ -120,12 +132,9 @@ function FormSteps({ }) { const [values, setValues] = useState(initialValues); const [activeErrors, setActiveErrors] = useState(initialActiveErrors); - const firstAlertRef = useRef(/** @type {?HTMLElement} */ (null)); const formRef = useRef(/** @type {?HTMLFormElement} */ (null)); - const headingRef = useRef(/** @type {?HTMLHeadingElement} */ (null)); const [stepName, setStepName] = useHistoryParam('step', null); const [stepErrors, setStepErrors] = useState(/** @type {Error[]} */ ([])); - const { t } = useI18n(); const fields = useRef(/** @type {Record} */ ({})); const didSubmitWithErrors = useRef(false); const forceRender = useForceRender(); @@ -141,20 +150,33 @@ function FormSteps({ const stepIndex = Math.max(getStepIndexByName(steps, stepName), 0); const step = steps[stepIndex]; + /** + * After a change in content, maintain focus by resetting to the beginning of the new content. + */ + function focusFirstContent() { + const firstElementChild = formRef.current?.firstElementChild; + if (firstElementChild instanceof window.HTMLElement) { + firstElementChild.classList.add('form-steps__focus-anchor'); + firstElementChild.setAttribute('tabindex', '-1'); + firstElementChild.focus(); + } + } + useEffect(() => { // Treat explicit initial step the same as step transition, placing focus to header. - if (autoFocus && headingRef.current) { - headingRef.current.focus(); + if (autoFocus) { + focusFirstContent(); } }, []); useEffect(() => { - if (stepErrors.length && firstAlertRef.current) { - firstAlertRef.current.focus(); + if (stepErrors.length) { + focusFirstContent(); } }, [stepErrors]); useDidUpdateEffect(onStepChange, [step]); + useDidUpdateEffect(focusFirstContent, [step]); /** * Returns array of form errors for the current set of values. @@ -183,7 +205,7 @@ function FormSteps({ const isValidStep = step.validator?.(values) ?? true; const hasUnresolvedFieldErrors = activeErrors.length && activeErrors.length > unknownFieldErrors.length; - const canContinue = isValidStep && !hasUnresolvedFieldErrors; + const canContinueToNextStep = isValidStep && !hasUnresolvedFieldErrors; /** * Increments state to the next step, or calls onComplete callback if the current step is the last @@ -218,77 +240,75 @@ function FormSteps({ const { name: nextStepName } = steps[nextStepIndex]; setStepName(nextStepName); } - - headingRef.current?.focus(); } - const { form: Form, footer: Footer, name, title } = step; + const { form: Form, name } = step; const isLastStep = stepIndex + 1 === steps.length; return (
    {Object.keys(values).length > 0 && } - {stepErrors.concat(unknownFieldErrors.map(({ error }) => error)).map((error, i) => ( - + {stepErrors.concat(unknownFieldErrors.map(({ error }) => error)).map((error) => ( + ))} - - {title} - - { - setActiveErrors((prevActiveErrors) => - prevActiveErrors.filter(({ field }) => !(field in nextValuesPatch)), - ); - setValues((prevValues) => ({ ...prevValues, ...nextValuesPatch })); - })} - onError={ifStillMounted((error, { field } = {}) => { - if (field) { - setActiveErrors((prevActiveErrors) => prevActiveErrors.concat({ field, error })); - } else { - setStepErrors([error]); - } - })} - registerField={(field, options = {}) => { - if (!fields.current[field]) { - fields.current[field] = { - refCallback(fieldNode) { - fields.current[field].element = fieldNode; - - if (activeErrors.length) { - forceRender(); - } - }, - element: null, - isRequired: !!options.isRequired, - }; - } - - return fields.current[field].refCallback; - }} - /> - - {Footer &&