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