diff --git a/app/javascript/packages/document-capture/components/address-search.tsx b/app/javascript/packages/document-capture/components/address-search.tsx index 8a83acc45f3..92c80e4904b 100644 --- a/app/javascript/packages/document-capture/components/address-search.tsx +++ b/app/javascript/packages/document-capture/components/address-search.tsx @@ -22,7 +22,6 @@ export const ADDRESS_SEARCH_URL = '/api/addresses'; function AddressSearch({ onAddressFound = () => {}, registerField }: AddressSearchProps) { const validatedFieldRef = useRef(null); const [unvalidatedAddressInput, setUnvalidatedAddressInput] = useState(''); - const [addressQuery, setAddressQuery] = useState({} as Location); const { t } = useI18n(); const handleAddressSearch = useCallback( @@ -32,14 +31,13 @@ function AddressSearch({ onAddressFound = () => {}, registerField }: AddressSear if (unvalidatedAddressInput === '') { return; } - const addressCandidates = await request(ADDRESS_SEARCH_URL, { + const addressCandidates = await request(ADDRESS_SEARCH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, json: { address: unvalidatedAddressInput }, }); const bestMatchedAddress = addressCandidates[0]; - setAddressQuery(bestMatchedAddress); onAddressFound(bestMatchedAddress); }, [unvalidatedAddressInput], @@ -68,7 +66,6 @@ function AddressSearch({ onAddressFound = () => {}, registerField }: AddressSear - <>{addressQuery.address} ); } diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx index 03a6d68a676..8350cef79e2 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx @@ -1,13 +1,12 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import { PageHeading, SpinnerDots } from '@18f/identity-components'; +import { PageHeading } from '@18f/identity-components'; import { request } from '@18f/identity-request'; +import useSWR from 'swr'; import BackButton from './back-button'; -import LocationCollection from './location-collection'; -import LocationCollectionItem from './location-collection-item'; import AnalyticsContext from '../context/analytics'; import AddressSearch from './address-search'; -import InPersonContext from '../context/in-person'; +import InPersonLocations, { FormattedLocation } from './in-person-locations'; interface PostOffice { address: string; @@ -22,31 +21,16 @@ interface PostOffice { zip_code_5: string; } -interface FormattedLocation { - formattedCityStateZip: string; - id: number; - name: string; - phone: string; - saturdayHours: string; - streetAddress: string; - sundayHours: string; - weekdayHours: string; -} interface LocationQuery { streetAddress: string; city: string; state: string; zipCode: string; + address: string; } export const LOCATIONS_URL = '/verify/in_person/usps_locations'; -const getUspsLocations = (address) => - request(LOCATIONS_URL, { - method: 'post', - json: { address }, - }); - const formatLocation = (postOffices: PostOffice[]) => { const formattedLocations = [] as FormattedLocation[]; postOffices.forEach((po: PostOffice, index) => { @@ -72,23 +56,32 @@ const snakeCase = (value: string) => .toLowerCase(); // snake case the keys of the location -const prepToSend = (location: object) => { +const transformKeys = (location: object, predicate: (key: string) => string) => { const sendObject = {}; Object.keys(location).forEach((key) => { - sendObject[snakeCase(key)] = location[key]; + sendObject[predicate(key)] = location[key]; }); return sendObject; }; +const requestUspsLocations = async (address: LocationQuery): Promise => { + const response = await request(LOCATIONS_URL, { + method: 'post', + json: { address: transformKeys(address, snakeCase) }, + }); + + return formatLocation(response); +}; + function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, registerField }) { const { t } = useI18n(); - const [locationData, setLocationData] = useState([] as FormattedLocation[]); - const [foundAddress, setFoundAddress] = useState({} as LocationQuery); + const [foundAddress, setFoundAddress] = useState(null); const [inProgress, setInProgress] = useState(false); const [autoSubmit, setAutoSubmit] = useState(false); - const [isLoadingComplete, setIsLoadingComplete] = useState(false); const { setSubmitEventMetadata } = useContext(AnalyticsContext); - const { arcgisSearchEnabled } = useContext(InPersonContext); + const { data: locationResults } = useSWR([LOCATIONS_URL, foundAddress], ([, address]) => + address ? requestUspsLocations(address) : null, + ); // ref allows us to avoid a memory leak const mountedRef = useRef(false); @@ -103,7 +96,7 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist // useCallBack here prevents unnecessary rerenders due to changing function identity const handleLocationSelect = useCallback( async (e: any, id: number) => { - const selectedLocation = locationData[id]; + const selectedLocation = locationResults![id]!; const { name: selectedLocationName } = selectedLocation; setSubmitEventMetadata({ selected_location: selectedLocationName }); onChange({ selectedLocationName }); @@ -115,7 +108,7 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist if (inProgress) { return; } - const selected = prepToSend(selectedLocation); + const selected = transformKeys(selectedLocation, snakeCase); setInProgress(true); await request(LOCATIONS_URL, { json: selected, @@ -140,7 +133,7 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist setInProgress(false); }); }, - [locationData, inProgress], + [locationResults, inProgress], ); const handleFoundAddress = useCallback((address) => { @@ -149,65 +142,23 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist city: address.city, state: address.state, zipCode: address.zip_code, + address: address.address, }); }, []); - useEffect(() => { - let didCancel = false; - (async () => { - try { - const fetchedLocations = await getUspsLocations(prepToSend(foundAddress)); - - if (!didCancel) { - const formattedLocations = formatLocation(fetchedLocations); - setLocationData(formattedLocations); - } - } finally { - if (!didCancel) { - setIsLoadingComplete(true); - } - } - })(); - return () => { - didCancel = true; - }; - }, [foundAddress]); - - let locationsContent: React.ReactNode; - if (!isLoadingComplete) { - locationsContent = ; - } else if (locationData.length < 1) { - locationsContent =

{t('in_person_proofing.body.location.none_found')}

; - } else { - locationsContent = ( - - {locationData.map((item, index) => ( - - ))} - - ); - } - return ( <> {t('in_person_proofing.headings.po_search.location')}

{t('in_person_proofing.body.location.po_search.po_search_about')}

- {arcgisSearchEnabled && ( - + + {locationResults && ( + )} -

{t('in_person_proofing.body.location.location_step_about')}

- {locationsContent} - + ); } diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx index 5933730fb6c..a603e2ed93d 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx @@ -77,11 +77,11 @@ describe('InPersonLocationStep', () => { await userEvent.click( await findByText('in_person_proofing.body.location.po_search.search_button'), ); - await findByText('100 Main St, South Fulton, Tennessee, 38257'); + await findByText('in_person_proofing.body.location.po_search.results_description'); expect(window.fetch).to.have.been.calledWith( LOCATIONS_URL, sandbox.match({ - body: '{"address":{"street_address":"100 Main St","city":"South Fulton","state":"TN","zip_code":"38257"}}', + body: '{"address":{"street_address":"100 Main St","city":"South Fulton","state":"TN","zip_code":"38257","address":"100 Main St, South Fulton, Tennessee, 38257"}}', method: 'post', }), ); diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.tsx index 23cd4647ade..be7a744126b 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.tsx @@ -40,7 +40,7 @@ interface LocationQuery { export const LOCATIONS_URL = '/verify/in_person/usps_locations'; const getUspsLocations = (address) => - request(LOCATIONS_URL, { + request(LOCATIONS_URL, { method: 'post', json: { address }, }); diff --git a/app/javascript/packages/document-capture/components/in-person-locations.tsx b/app/javascript/packages/document-capture/components/in-person-locations.tsx new file mode 100644 index 00000000000..c9e10bf1a6b --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-locations.tsx @@ -0,0 +1,62 @@ +import { useI18n } from '@18f/identity-react-i18n'; +import LocationCollection from './location-collection'; +import LocationCollectionItem from './location-collection-item'; + +export interface FormattedLocation { + formattedCityStateZip: string; + id: number; + name: string; + phone: string; + saturdayHours: string; + streetAddress: string; + sundayHours: string; + weekdayHours: string; +} + +interface InPersonLocationsProps { + locations: FormattedLocation[] | null | undefined; + onSelect; + address: string; +} + +function InPersonLocations({ locations, onSelect, address }: InPersonLocationsProps) { + const { t } = useI18n(); + + if (locations?.length === 0) { + return ( + <> +

{t('in_person_proofing.body.location.po_search.none_found', { address })}

+

{t('in_person_proofing.body.location.po_search.none_found_tip')}

+ + ); + } + + return ( + <> +

+ {t('in_person_proofing.body.location.po_search.results_description', { + address, + count: locations?.length, + })} +

+

{t('in_person_proofing.body.location.po_search.results_instructions')}

+ + {(locations || []).map((item, index) => ( + + ))} + + + ); +} + +export default InPersonLocations; diff --git a/app/javascript/packages/document-capture/package.json b/app/javascript/packages/document-capture/package.json index 5beb2ac765b..76877772573 100644 --- a/app/javascript/packages/document-capture/package.json +++ b/app/javascript/packages/document-capture/package.json @@ -4,6 +4,7 @@ "version": "1.0.0", "dependencies": { "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "swr": "^2.0.0" } } diff --git a/app/javascript/packages/request/index.ts b/app/javascript/packages/request/index.ts index 411c08f5ad5..b7854df46cb 100644 --- a/app/javascript/packages/request/index.ts +++ b/app/javascript/packages/request/index.ts @@ -15,7 +15,10 @@ interface RequestOptions extends RequestInit { const getCSRFToken = () => document.querySelector('meta[name="csrf-token"]')?.content; -export const request = async (url: string, options: Partial = {}) => { +export async function request( + url: string, + options: Partial = {}, +): Promise { const { csrf = true, json = true, ...fetchOptions } = options; let { body, headers } = fetchOptions; headers = new Headers(headers); @@ -39,4 +42,4 @@ export const request = async (url: string, options: Partial = {} const response = await window.fetch(url, { ...fetchOptions, headers, body }); return json ? response.json() : response.text(); -}; +} diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index 7c271125492..1b83266fd48 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -35,8 +35,17 @@ en: po_search: address_search_hint: 'Example: 1960 W Chelsea Ave Allentown PA 18104' address_search_label: Enter an address to find a Post Office near you + none_found: Sorry, there are no participating Post Offices within 50 miles of + %{address}. + none_found_tip: You can search using a different address, or add photos of your + ID to try and verify your identity online again. po_search_about: If you are having trouble adding your ID, you may be able to verify your identity in person at a local United States Post Office. + results_description: There are %{count} participating Post Offices within 50 + miles of %{address}. + results_instructions: Select a Post Office location below, or search again using + a different address. For facility accessibility, use the contact + information listed for the Post Office location. search_button: Search post_office: Post Office ™ retail_hours_heading: Retail Hours diff --git a/config/locales/in_person_proofing/es.yml b/config/locales/in_person_proofing/es.yml index eea0d5e0881..83491a601e6 100644 --- a/config/locales/in_person_proofing/es.yml +++ b/config/locales/in_person_proofing/es.yml @@ -40,9 +40,20 @@ es: address_search_hint: 'Ejemplo: 1960 W Chelsea Ave Allentown PA 18104' address_search_label: Introduzca una dirección para encontrar una Oficina de Correos cercana a usted + none_found: Lo sentimos, no hay Oficinas de Correos participantes en un radio de + 50 millas de la %{address}. + none_found_tip: Puede buscar utilizando una dirección diferente, o añadir fotos + de su documento de identidad para intentar verificar su identidad en + línea de nuevo. po_search_about: Si tiene problemas para añadir su documento de identidad, es posible que pueda verificar su identidad en persona en una oficina de correos local de los Estados Unidos. + results_description: Hay %{count} de oficinas de correos participantes en un + radio de 50 millas de la %{address}. + results_instructions: Seleccione una ubicación de la Oficina de Correos a + continuación, o busque de nuevo utilizando una dirección diferente. + Para la accesibilidad de las instalaciones, utilice la información + de contacto que aparece para la ubicación de la Oficina de Correos. search_button: Búsqueda post_office: Oficina de Correos retail_hours_heading: Horario de atención al público diff --git a/config/locales/in_person_proofing/fr.yml b/config/locales/in_person_proofing/fr.yml index 070abbaf7b0..bba0731c5d4 100644 --- a/config/locales/in_person_proofing/fr.yml +++ b/config/locales/in_person_proofing/fr.yml @@ -40,10 +40,20 @@ fr: po_search: address_search_hint: 'Exemple : 1960 W Chelsea Ave Allentown PA 18104' address_search_label: Entrez une adresse pour trouver un bureau de poste près de chez vous. + none_found: Placeholder %{address} + none_found_tip: Vous pouvez effectuer une recherche en utilisant une autre + adresse, ou ajouter des photos de votre pièce d’identité pour + essayer de vérifier à nouveau votre identité en ligne. po_search_about: Si vous avez des difficultés à ajouter votre pièce d’identité, vous pouvez vérifier votre identité en personne dans un bureau de poste américain proche. - search_button: Chercher + results_description: Il y a %{count} de bureaux de poste participants dans un + rayon de 80 km autour de %{address}. + results_instructions: Sélectionnez un emplacement de bureau de poste ci-dessous, + ou effectuez une nouvelle recherche en utilisant une autre adresse. + Pour l’accessibilité des installations, utilisez les informations de + contact indiquées pour l’emplacement du bureau de poste. + search_button: Rechercher post_office: Bureau de Poste retail_hours_heading: Heures de vente au détail retail_hours_sat: 'Sam:' diff --git a/webpack.config.js b/webpack.config.js index fafdea71132..265090dfccc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -43,7 +43,7 @@ module.exports = /** @type {import('webpack').Configuration} */ ({ devServerPort && isLocalhost ? `http://localhost:${devServerPort}/packs/` : '/packs/', }, resolve: { - extensions: ['.js', '.jsx', '.ts', '.tsx'], + extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'], }, module: { rules: [ @@ -54,9 +54,9 @@ module.exports = /** @type {import('webpack').Configuration} */ ({ use: ['source-map-loader'], }, { - test: /\.[jt]sx?$/, + test: /\.[cm]?[jt]sx?$/, exclude: - /node_modules\/(?!@18f\/identity-|identity-style-guide|uswds|receptor|elem-dataset)/, + /node_modules\/(?!@18f\/identity-|identity-style-guide|uswds|receptor|elem-dataset|swr)/, use: { loader: 'babel-loader', }, diff --git a/yarn.lock b/yarn.lock index a63f6e0c83b..5f07fe15ba0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5995,6 +5995,13 @@ svgo@^2.8.0: picocolors "^1.0.0" stable "^0.1.8" +swr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.0.0.tgz#91d999359e2be92de1a41f6b6711d72be20ffdbd" + integrity sha512-IhUx5yPkX+Fut3h0SqZycnaNLXLXsb2ECFq0Y29cxnK7d8r7auY2JWNbCW3IX+EqXUg3rwNJFlhrw5Ye/b6k7w== + dependencies: + use-sync-external-store "^1.2.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -6227,6 +6234,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + uswds@^2.13.3: version "2.13.3" resolved "https://registry.yarnpkg.com/uswds/-/uswds-2.13.3.tgz#f2a0623b496941ff30ad3a0ea1610407d35a6b14"