diff --git a/app/assets/stylesheets/components/_location-collection-item.scss b/app/assets/stylesheets/components/_location-collection-item.scss new file mode 100644 index 00000000000..f9785837de0 --- /dev/null +++ b/app/assets/stylesheets/components/_location-collection-item.scss @@ -0,0 +1,40 @@ +.location-collection-item { + max-width: 64ex; + margin-bottom: 0; + margin-top: 0; + list-style-type: none; + padding-left: 0; + align-items: flex-start; + border-bottom-width: 1px; + border-bottom-style: solid; + display: flex; + margin-bottom: 1rem; + margin-top: 1rem; + padding-bottom: 1rem; + border-color: #CEDCED; +} + +@media screen { + .wrap-name{ + overflow-wrap: break-word; + } +} + +@media screen and (min-width: 320px){ + .usa-button-mobile { + width: -webkit-fill-available; + margin-top: 8px; + } +} + +@media screen and (max-width: 480px){ + .usa-button-mobile-hidden { + display: none; + } +} + +@media screen and (min-width: 481px){ + .usa-button-desktop-hidden { + display: none; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index ee295a189d1..41e20125440 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -27,3 +27,4 @@ @import 'troubleshooting-options'; @import 'validated-checkbox'; @import 'i18n-dropdown'; +@import 'location-collection-item'; diff --git a/app/controllers/idv/in_person/usps_locations_controller.rb b/app/controllers/idv/in_person/usps_locations_controller.rb new file mode 100644 index 00000000000..246f466c8a3 --- /dev/null +++ b/app/controllers/idv/in_person/usps_locations_controller.rb @@ -0,0 +1,18 @@ +require "json" + +module Idv + module InPerson + class UspsLocationsController < ApplicationController + + def index + begin + uspsResponse = UspsInPersonProofer.new.request_pilot_facilities() + rescue Faraday::ConnectionFailed => error + print error + end + + render body: uspsResponse.to_json, content_type: 'application/json' + end + end + end +end diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index 5c6b215a486..426b984c260 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -12,6 +12,8 @@ export { default as IconListItem } from './icon-list/icon-list-item'; export { default as IconListTitle } from './icon-list/icon-list-title'; export { default as Link } from './link'; export { default as PageFooter } from './page-footer'; +export { default as LocationCollection } from './location-collection'; +export { default as LocationCollectionItem } from './location-collection-item'; export { default as PageHeading } from './page-heading'; export { default as ProcessList } from './process-list/process-list'; export { default as ProcessListHeading } from './process-list/process-list-heading'; diff --git a/app/javascript/packages/components/location-collection-item.spec.tsx b/app/javascript/packages/components/location-collection-item.spec.tsx new file mode 100644 index 00000000000..fa851432694 --- /dev/null +++ b/app/javascript/packages/components/location-collection-item.spec.tsx @@ -0,0 +1,57 @@ +import { render } from '@testing-library/react'; +import LocationCollectionItem from './location-collection-item'; + +describe('LocationCollectionItem', () => { + it('renders the component with expected class and children', () => { + const { container } = render( + , + ); + + const wrapper = container.firstElementChild!; + expect(wrapper.classList.contains('location-collection-item')).to.be.true(); + const locationCollectionItem = wrapper.firstElementChild!; + expect(locationCollectionItem.classList.contains('usa-collection__body')).to.be.true(); + const display = locationCollectionItem.firstElementChild!; + expect(display.classList.contains('display-flex')).to.be.true(); + expect(display.classList.contains('flex-justify')).to.be.true(); + const heading = display.firstElementChild!; + expect(heading.classList.contains('usa-collection__heading')).to.be.true(); + }); + + it('renders the component with expected data', () => { + const { getByText } = render( + , + ); + + const name = getByText('123 Test Address').parentElement!; + expect(name.textContent).to.contain('test name'); + const streetAddress = getByText('123 Test Address').parentElement!; + expect(streetAddress.textContent).to.contain('123 Test Address'); + const addressLine2 = getByText('123 Test Address').parentElement!; + expect(addressLine2.textContent).to.contain('City, State 12345-1234'); + const wkDayHours = getByText( + 'in_person_proofing.body.location.retail_hours_weekday 9 AM - 5 PM', + ).parentElement!; + expect(wkDayHours.textContent).to.contain('9 AM - 5 PM'); + const satHours = getByText('in_person_proofing.body.location.retail_hours_sat 9 AM - 6 PM') + .parentElement!; + expect(satHours.textContent).to.contain('9 AM - 6 PM'); + const sunHours = getByText('in_person_proofing.body.location.retail_hours_sun Closed') + .parentElement!; + expect(sunHours.textContent).to.contain('Closed'); + }); +}); diff --git a/app/javascript/packages/components/location-collection-item.tsx b/app/javascript/packages/components/location-collection-item.tsx new file mode 100644 index 00000000000..a6f8583089e --- /dev/null +++ b/app/javascript/packages/components/location-collection-item.tsx @@ -0,0 +1,45 @@ +import { Button } from '@18f/identity-components'; +import { useI18n } from '@18f/identity-react-i18n'; + +export interface LocationCollectionItemProps { + name: string; + streetAddress: string; + addressLine2: string; + weekdayHours: string; + saturdayHours: string; + sundayHours: string; +} + +function LocationCollectionItem({ + name, + streetAddress, + addressLine2, + weekdayHours, + saturdayHours, + sundayHours, +}: LocationCollectionItemProps) { + const { t } = useI18n(); + return ( +
  • +
    +
    +

    {name}

    + +
    +
    {streetAddress}
    +
    {addressLine2}
    +

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

    +
    {`${t('in_person_proofing.body.location.retail_hours_weekday')} ${weekdayHours}`}
    +
    {`${t('in_person_proofing.body.location.retail_hours_sat')} ${saturdayHours}`}
    +
    {`${t('in_person_proofing.body.location.retail_hours_sun')} ${sundayHours}`}
    + +
    +
  • + ); +} + +export default LocationCollectionItem; diff --git a/app/javascript/packages/components/location-collection.spec.tsx b/app/javascript/packages/components/location-collection.spec.tsx new file mode 100644 index 00000000000..9f3a86d17a2 --- /dev/null +++ b/app/javascript/packages/components/location-collection.spec.tsx @@ -0,0 +1,31 @@ +import { render } from '@testing-library/react'; +import LocationCollection from './location-collection'; + +describe('LocationCollection', () => { + it('renders the component with expected class and children', () => { + const { getByText } = render( + +
    LCI
    +
    , + ); + + const child = getByText('LCI'); + const item = child.parentElement!; + + expect(item.classList.contains('usa-collection')).to.be.true(); + expect(item.textContent).to.equal('LCI'); + }); + + it('renders the component with custom class', () => { + const { getByText } = render( + +
    LCI
    +
    , + ); + + const child = getByText('LCI'); + const item = child.parentElement!; + + expect(item.classList.contains('custom-class')).to.be.true(); + }); +}); diff --git a/app/javascript/packages/components/location-collection.tsx b/app/javascript/packages/components/location-collection.tsx new file mode 100644 index 00000000000..d5747c2963f --- /dev/null +++ b/app/javascript/packages/components/location-collection.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +interface LocationCollectionProps { + className?: string; + + children?: ReactNode; +} + +function LocationCollection({ children, className }: LocationCollectionProps) { + const classes = ['usa-collection', className].filter(Boolean).join(' '); + return ; +} + +export default LocationCollection; 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 5e16eb8e4ed..05e8abc5a0a 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 @@ -1,14 +1,80 @@ -import { PageHeading } from '@18f/identity-components'; -import { FormStepsButton } from '@18f/identity-form-steps'; +import { useState, useEffect } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; +import { PageHeading, LocationCollectionItem, LocationCollection } from '@18f/identity-components'; +import { LocationCollectionItemProps } from '@18f/identity-components/location-collection-item'; + +interface PostOffice { + address: string; + city: string; + name: string; + saturday_hours: string; + state: string; + sunday_hours: string; + weekday_hours: string; + zip_code_4: string; + zip_code_5: string; +} + +const getResponse = async () => { + const response = await fetch('http://localhost:3000/verify/in_person/usps_locations').then( + (res) => + res.json().catch((error) => { + throw error; + }), + ); + return response; +}; + +const formatLocation = (postOffices: PostOffice[]) => { + const formattedLocations = [] as LocationCollectionItemProps[]; + postOffices.forEach((po: PostOffice) => { + const location = { + name: po.name, + streetAddress: po.address, + addressLine2: `${po.city}, ${po.state}, ${po.zip_code_5}-${po.zip_code_4}`, + weekdayHours: po.weekday_hours, + saturdayHours: po.saturday_hours, + sundayHours: po.sunday_hours, + } as LocationCollectionItemProps; + formattedLocations.push(location); + }); + return formattedLocations; +}; function InPersonLocationStep() { const { t } = useI18n(); + const [locationData, setLocationData] = useState([] as LocationCollectionItemProps[]); + + useEffect(() => { + (async () => { + const fetchedLocations = await getResponse().catch((error) => { + throw error; + }); + const formattedLocations = formatLocation(fetchedLocations); + setLocationData(formattedLocations); + })(); + }, []); return ( <> {t('in_person_proofing.headings.location')} - + +

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

    + + {locationData && + locationData.map((item) => ( + + ))} + {locationData.length < 1 &&

    No locations found.

    } +
    ); } diff --git a/app/services/usps_in_person_proofer.rb b/app/services/usps_in_person_proofer.rb index 3fc000e41ec..1a880fa88d9 100644 --- a/app/services/usps_in_person_proofer.rb +++ b/app/services/usps_in_person_proofer.rb @@ -2,7 +2,18 @@ class UspsInPersonProofer attr_reader :token, :token_expires_at PostOffice = Struct.new( - :distance, :address, :city, :phone, :name, :zip_code, :state, keyword_init: true + :address, + :city, + :distance, + :name, + :phone, + :saturday_hours, + :state, + :sunday_hours, + :weekday_hours, + :zip_code_4, + :zip_code_5, + keyword_init: true, ) # Makes HTTP request to get nearby in-person proofing facilities @@ -19,23 +30,27 @@ def request_facilities(location) city: location.city, state: location.state, zipCode: location.zip_code, - } + }.to_json - resp = faraday.post(url, body, dynamic_headers) + headers = request_headers.merge( + 'Authorization' => @token, + 'RequestID' => request_id, + ) - resp.body['postOffices'].map do |post_office| - PostOffice.new( - distance: post_office['distance'], - address: post_office['streetAddress'], - city: post_office['city'], - phone: post_office['phone'], - name: post_office['name'], - zip_code: post_office['zip5'], - state: post_office['state'], - ) + resp = faraday.post(url, body, headers) + if resp.success? + parse_facilities(resp.body) + else + { error: 'failed to get facilities', response: resp } end end + # Temporary function to return a static set of facilities + def request_pilot_facilities + resp = File.read('spec/fixtures/usps_ipp_responses/request_facilities_response.json') + parse_facilities(resp) + end + # Makes HTTP request to enroll an applicant in in-person proofing. # Requires first name, last name, address, city, state, zip code, email address and a generated # unique ID. The unique ID must be no longer than 18 characters. @@ -181,4 +196,31 @@ def request_id def request_headers { 'Content-Type' => 'application/json; charset=utf-8' } end + + private + + def parse_facilities(facilities) + JSON.parse(facilities)['postOffices'].map do |post_office| + hours = {} + post_office['hours'].each do |hour_details| + hour_details.keys.each do |key| + hours[key] = hour_details[key] + end + end + + PostOffice.new( + address: post_office['streetAddress'], + city: post_office['city'], + distance: post_office['distance'], + name: post_office['name'], + phone: post_office['phone'], + saturday_hours: hours['saturdayHours'], + state: post_office['state'], + sunday_hours: hours['sundayHours'], + weekday_hours: hours['weekdayHours'], + zip_code_4: post_office['zip4'], + zip_code_5: post_office['zip5'], + ) + end + end end diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index faf2ce8e0e4..ca051e86fbb 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -21,6 +21,16 @@ en: retail_phone_label: Phone number speak_to_associate: You can speak with any retail associate at this Post Office to verify your identity. + location: + location_button: 'Select' + location_step_about: 'If you are having trouble adding your ID, you may be able + to verify in person at a local United States Post Office in select + locations.' + post_office: 'Post Office ™' + retail_hours_heading: 'Retail Hours' + retail_hours_sat: 'Sat:' + retail_hours_sun: 'Sun:' + retail_hours_weekday: 'Monday to Friday:' prepare: alert_selected_post_office: 'You’ve selected the %{name} Post Office.' bring_barcode_header: A copy of your barcode diff --git a/config/routes.rb b/config/routes.rb index aaf5302755d..475d8e57194 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -329,6 +329,7 @@ get '/in_person' => 'in_person#index' get '/in_person/ready_to_verify' => 'in_person/ready_to_verify#show', as: :in_person_ready_to_verify + get '/in_person/usps_locations' => 'in_person/usps_locations#index' get '/in_person/:step' => 'in_person#show', as: :in_person_step put '/in_person/:step' => 'in_person#update' diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 08550106ffa..bb96c61a89b 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -30,6 +30,10 @@ def click_idv_continue click_spinner_button_and_wait t('forms.buttons.continue') end + def click_idv_select + click_select_button_and_wait t('in_person_proofing.body.location.location_button') + end + def choose_idv_otp_delivery_method_sms page.find( 'label', diff --git a/spec/support/features/in_person_helper.rb b/spec/support/features/in_person_helper.rb index e2490ae8c92..a2ce239b60b 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -44,7 +44,7 @@ def begin_in_person_proofing(user = user_with_2fa) end def complete_location_step(_user = user_with_2fa) - click_idv_continue + click_idv_select end def complete_prepare_step(_user = user_with_2fa) diff --git a/spec/support/features/interaction_helper.rb b/spec/support/features/interaction_helper.rb index c6d57ade971..4a83a982396 100644 --- a/spec/support/features/interaction_helper.rb +++ b/spec/support/features/interaction_helper.rb @@ -3,4 +3,9 @@ def click_spinner_button_and_wait(...) click_button(...) expect(page).to have_no_css('lg-spinner-button.spinner-button--spinner-active', wait: 10) end + + def click_select_button_and_wait(...) + click_button(...) + expect(page).to have_no_css('button.usa-button', wait: 10) + end end