diff --git a/.env.example b/.env.example index 8dc48032..3963f06b 100644 --- a/.env.example +++ b/.env.example @@ -7,5 +7,6 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY=cloudflare_turnstile_site_key_goes_here TURNSTILE_SECRET_KEY=cloudflare_turnstile_secret_key_goes_here NEXT_PUBLIC_SUPABASE_URL=supabase_api_url_goes_here NEXT_PUBLIC_SUPABASE_ANON_KEY=supabase_anon_key_goes_here -SUPABASE_SERVICE_ROLE_KEY=supabase_service_role_key_goes_here +SUPABASE_SERVICE_ROLE_KEY=supabase_service_role_key_goes_here +GOOGLE_MAPS_API_KEY=google_maps_api_key_here VOTER_REGISTRATION_REPO_ENCRYPTION_KEY="result of npm run create-cryptokey" \ No newline at end of file diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 3453634d..4e3d0d5a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -13,6 +13,7 @@ jobs: NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + GOOGLE_MAPS_API_KEY: '' VOTER_REGISTRATION_REPO_ENCRYPTION_KEY: 'DvViBWSQfwFGSetPOVbIWZrMXYJh4wTVSE/+1QI/VTI=' steps: - uses: actions/checkout@v4 diff --git a/jest.config.mjs b/jest.config.mjs index ab5ff541..4e9df6d6 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -23,7 +23,13 @@ const config = { '/src/constants/', 'fonts', '/src/model/', - 'user-context/user-context-provider.tsx', + '/src/app/register/progress-bar', + // ignore async server components as the test environment doesn't support rendering them at this time + '/src/contexts/user-context/user-context-provider.tsx', + '/src/app/register/addresses/page.tsx', + '/src/app/register/eligibility/page.tsx', + '/src/app/register/names/page.tsx', + '/src/app/register/other-details/page.tsx', ], //require 100% code coverage for the tests to pass coverageThreshold: { diff --git a/package-lock.json b/package-lock.json index 91448b81..1c99306f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@paralleldrive/cuid2": "^2.2.2", "@supabase/ssr": "^0.4.0", "@supabase/supabase-js": "^2.44.0", - "fully-formed": "^1.0.1", + "fully-formed": "^1.2.1", "luxon": "^3.4.4", "next": "^14.2.4", "react": "^18.3.1", @@ -11831,9 +11831,9 @@ } }, "node_modules/fully-formed": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fully-formed/-/fully-formed-1.0.1.tgz", - "integrity": "sha512-LANG6ubGDdttvv6ed8ogEMbq/RnIYI/tSNLo4yK+cHJFpYiT0ZIxUaD0qH2rZPJi4nf4uQtL7l/3loX2AhoWKw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fully-formed/-/fully-formed-1.2.1.tgz", + "integrity": "sha512-QZr6XWK5VKN7xftt7O8V/LviQjBcJ5CGDAozCSoNO+eygzD4OAGise6K2iALZS3Y3gFq6byEUuB11rKeITwZ9Q==", "license": "MIT", "dependencies": { "just-clone": "^6.2.0", diff --git a/package.json b/package.json index aee82d02..454ff580 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@paralleldrive/cuid2": "^2.2.2", "@supabase/ssr": "^0.4.0", "@supabase/supabase-js": "^2.44.0", - "fully-formed": "^1.0.1", + "fully-formed": "^1.2.1", "luxon": "^3.4.4", "next": "^14.2.4", "react": "^18.3.1", diff --git a/public/static/images/components/checkbox/checkmark.svg b/public/static/images/components/checkbox/checkmark.svg new file mode 100644 index 00000000..8aa1324d --- /dev/null +++ b/public/static/images/components/checkbox/checkmark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/components/shared/warning-icon-dark.svg b/public/static/images/components/shared/warning-icon-dark.svg new file mode 100644 index 00000000..7a0ea0f4 --- /dev/null +++ b/public/static/images/components/shared/warning-icon-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/components/shared/warning-icon-light.svg b/public/static/images/components/shared/warning-icon-light.svg new file mode 100644 index 00000000..652ac7d2 --- /dev/null +++ b/public/static/images/components/shared/warning-icon-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/__tests__/unit/app/api/validate-addresses/route.test.tsx b/src/__tests__/unit/app/api/validate-addresses/route.test.tsx new file mode 100644 index 00000000..9da3b2d2 --- /dev/null +++ b/src/__tests__/unit/app/api/validate-addresses/route.test.tsx @@ -0,0 +1,271 @@ +import { POST } from '@/app/api/validate-addresses/route'; +import { serverContainer } from '@/services/server/container'; +import { SERVER_SERVICE_KEYS } from '@/services/server/keys'; +import { saveActualImplementation } from '@/utils/test/save-actual-implementation'; +import { NextRequest } from 'next/server'; +import { AddressErrorTypes } from '@/model/types/addresses/address-error-types'; +import { ServerError } from '@/errors/server-error'; +import type { ValidateAddresses } from '@/services/server/validate-addresses/validate-addresses'; +import type { AddressErrors } from '@/model/types/addresses/address-errors'; + +describe('POST', () => { + const getActualService = saveActualImplementation(serverContainer, 'get'); + + it(`returns a response with a result.errors property containing an empty array + if all addresses were valid.`, async () => { + const containerSpy = jest + .spyOn(serverContainer, 'get') + .mockImplementation(key => { + if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) { + const validateAddresses: ValidateAddresses = () => { + return Promise.resolve([] as AddressErrors[]); + }; + + return validateAddresses; + } + + return getActualService(key); + }); + + const request = new NextRequest( + 'https://challenge.8by8.us/api/validate-addresses', + { + method: 'POST', + body: JSON.stringify({ + homeAddress: { + streetLine1: '1600 Amphitheatre Pkwy', + city: 'Mountain View', + state: 'CA', + zip: '94043', + }, + }), + }, + ); + + const response = await POST(request); + const body = await response.json(); + expect(body).toEqual({ + result: { + errors: [], + }, + }); + + containerSpy.mockRestore(); + }); + + it(`returns a response with a result.errors property containing errors if any + were detected.`, async () => { + const addresses = { + homeAddress: { + streetLine1: '2930 Pearl Street', + streetLine2: 'Suite 100', + city: 'Boulder', + state: 'CO', + zip: '80301', + }, + mailingAddress: { + streetLine1: '1600 Amphitheatre Pkwy', + city: 'Montan View', + state: 'CA', + zip: '94043', + }, + previousAddress: { + streetLine1: '500 W 2nd St', + city: 'Austin', + state: 'TX', + zip: '78701', + }, + }; + + const errors: AddressErrors[] = [ + { + type: AddressErrorTypes.UnconfirmedComponents, + form: 'homeAddress', + unconfirmedAddressComponents: { + streetLine1: { + value: addresses.homeAddress.streetLine1, + hasIssue: false, + }, + streetLine2: { + value: addresses.homeAddress.streetLine2, + hasIssue: true, + }, + city: { + value: addresses.homeAddress.city, + hasIssue: false, + }, + state: { + value: addresses.homeAddress.state, + hasIssue: false, + }, + zip: { + value: addresses.homeAddress.zip, + hasIssue: false, + }, + }, + }, + { + type: AddressErrorTypes.ReviewRecommendedAddress, + form: 'mailingAddress', + enteredAddress: { + streetLine1: { + value: addresses.mailingAddress.streetLine1, + hasIssue: false, + }, + city: { + value: addresses.mailingAddress.city, + hasIssue: true, + }, + state: { + value: addresses.mailingAddress.state, + hasIssue: false, + }, + zip: { + value: addresses.mailingAddress.zip, + hasIssue: false, + }, + }, + recommendedAddress: { + streetLine1: { + value: addresses.mailingAddress.streetLine1, + hasIssue: false, + }, + city: { + value: addresses.mailingAddress.city, + hasIssue: true, + }, + state: { + value: addresses.mailingAddress.state, + hasIssue: false, + }, + zip: { + value: addresses.mailingAddress.zip, + hasIssue: false, + }, + }, + }, + { + type: AddressErrorTypes.MissingSubpremise, + form: 'previousAddress', + }, + ]; + + const containerSpy = jest + .spyOn(serverContainer, 'get') + .mockImplementation(key => { + if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) { + const validateAddresses: ValidateAddresses = () => { + return Promise.resolve(errors); + }; + + return validateAddresses; + } + + return getActualService(key); + }); + + const request = new NextRequest( + 'https://challenge.8by8.us/api/validate-addresses', + { + method: 'POST', + body: JSON.stringify(addresses), + }, + ); + + const response = await POST(request); + const body = await response.json(); + expect(body).toEqual({ + result: { + errors, + }, + }); + + containerSpy.mockRestore(); + }); + + it(`returns a response with a status of 400 if the data sent in the body + could not be parsed.`, async () => { + const request = new NextRequest( + 'https://challenge.8by8.us/api/validate-addresses', + { + method: 'POST', + body: JSON.stringify({}), + }, + ); + + const response = await POST(request); + expect(response.status).toBe(400); + }); + + it('returns a response with a status matching that of a caught ServerError.', async () => { + const containerSpy = jest + .spyOn(serverContainer, 'get') + .mockImplementation(key => { + if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) { + const validateAddresses: ValidateAddresses = () => { + throw new ServerError('Too many requests.', 429); + }; + + return validateAddresses; + } + + return getActualService(key); + }); + + const request = new NextRequest( + 'https://challenge.8by8.us/api/validate-addresses', + { + method: 'POST', + body: JSON.stringify({ + homeAddress: { + streetLine1: '1600 Amphitheatre Pkwy', + city: 'Mountain View', + state: 'CA', + zip: '94043', + }, + }), + }, + ); + + const response = await POST(request); + expect(response.status).toBe(429); + + containerSpy.mockRestore(); + }); + + it(`returns a response with a status of 500 when an unknown error is encountered.`, async () => { + const containerSpy = jest + .spyOn(serverContainer, 'get') + .mockImplementation(key => { + if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) { + const validateAddresses: ValidateAddresses = () => { + throw new Error(); + }; + + return validateAddresses; + } + + return getActualService(key); + }); + + const request = new NextRequest( + 'https://challenge.8by8.us/api/validate-addresses', + { + method: 'POST', + body: JSON.stringify({ + homeAddress: { + streetLine1: '1600 Amphitheatre Pkwy', + city: 'Mountain View', + state: 'CA', + zip: '94043', + }, + }), + }, + ); + + const response = await POST(request); + expect(response.status).toBe(500); + + containerSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap b/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap index 43c210be..2d194cbf 100644 --- a/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap +++ b/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap @@ -94,11 +94,12 @@ exports[`Progress renders progress page unchanged 1`] = ` Not registered to vote yet?
Register now - and earn a badge! + + and earn a badge!

@@ -355,11 +356,12 @@ exports[`Progress renders progress page unchanged 1`] = ` Not registered to vote yet?
Register now - and earn a badge! + + and earn a badge!

@@ -375,6 +377,7 @@ exports[`Progress renders progress page unchanged 1`] = ` + + ); +} diff --git a/src/app/register/addresses/address-confirmation-modal/missing-subpremise/styles.module.scss b/src/app/register/addresses/address-confirmation-modal/missing-subpremise/styles.module.scss new file mode 100644 index 00000000..b4c1ce41 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/missing-subpremise/styles.module.scss @@ -0,0 +1,36 @@ +@use '../../../../../styles/partials' as *; + +.container { + max-width: 100%; +} + +.title { + @extend .b1; + margin-bottom: 5px; +} + +.caption { + @extend .b2; + margin-bottom: 5px; +} + +.error_number_of_count { + @extend .b2; + margin-bottom: 30px; +} + +.what_you_entered { + @extend.b3; + margin-bottom: 5px; +} + +.address { + margin-bottom: 30px; + max-width: 100%; + overflow-wrap: break-word; +} + +.missing_information { + @extend.b3; + margin-bottom: 10px; +} diff --git a/src/app/register/addresses/address-confirmation-modal/review-addresses/index.tsx b/src/app/register/addresses/address-confirmation-modal/review-addresses/index.tsx new file mode 100644 index 00000000..76de5517 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/review-addresses/index.tsx @@ -0,0 +1 @@ +export { ReviewAddresses } from './review-addresses'; diff --git a/src/app/register/addresses/address-confirmation-modal/review-addresses/review-addresses.tsx b/src/app/register/addresses/address-confirmation-modal/review-addresses/review-addresses.tsx new file mode 100644 index 00000000..8fa624a4 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/review-addresses/review-addresses.tsx @@ -0,0 +1,70 @@ +import { FormattedAddress } from '../formatted-address'; +import { Button } from '@/components/utils/button'; +import type { Address } from '../../../../../model/types/addresses/address'; +import styles from './styles.module.scss'; + +interface ReviewAddressesProps { + homeAddress: Address; + mailingAddress?: Address; + previousAddress?: Address; + returnToEditing: () => void; + continueToNextPage: () => void; +} + +export function ReviewAddresses({ + homeAddress, + mailingAddress, + previousAddress, + returnToEditing, + continueToNextPage, +}: ReviewAddressesProps) { + return ( +
+

+ {(mailingAddress || previousAddress ? + 'These are the addresses you entered.' + : 'This is the address you entered.') + ' Would you like to continue?'} +

+

Home Address

+ + {mailingAddress && ( + <> +

Mailing Address

+ + + )} + {previousAddress && ( + <> +

Previous Address

+ + + )} +
+ + +
+
+ ); +} diff --git a/src/app/register/addresses/address-confirmation-modal/review-addresses/styles.module.scss b/src/app/register/addresses/address-confirmation-modal/review-addresses/styles.module.scss new file mode 100644 index 00000000..1cac5733 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/review-addresses/styles.module.scss @@ -0,0 +1,26 @@ +@use '../../../../../styles/partials' as *; + +.container { + max-width: 100%; +} + +.title { + margin: 0; + padding: 0; + margin-bottom: 16px; +} + +.address_title { + @extend .b3; + margin: 0; + padding: 0; + margin-bottom: 5px; +} + +.address { + margin: 0; + padding: 0; + margin-bottom: 16px; + max-width: 100%; + overflow-wrap: break-word; +} diff --git a/src/app/register/addresses/address-confirmation-modal/review-recommended-address/index.tsx b/src/app/register/addresses/address-confirmation-modal/review-recommended-address/index.tsx new file mode 100644 index 00000000..9fee4fbd --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/review-recommended-address/index.tsx @@ -0,0 +1 @@ +export { ReviewRecommendedAddress } from './review-recommended-address'; diff --git a/src/app/register/addresses/address-confirmation-modal/review-recommended-address/review-recommended-address.tsx b/src/app/register/addresses/address-confirmation-modal/review-recommended-address/review-recommended-address.tsx new file mode 100644 index 00000000..c8ce4373 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/review-recommended-address/review-recommended-address.tsx @@ -0,0 +1,105 @@ +'use client'; +import { useRef, useId } from 'react'; +import { FormattedAddress } from '../formatted-address'; +import { Button } from '@/components/utils/button'; +import type { AddressComponents } from '@/model/types/addresses/address-components'; +import type { AddressForm } from '../types/address-form'; +import styles from './styles.module.scss'; + +interface ReviewRecommendedAddressProps { + enteredAddress: AddressComponents; + recommendedAddress: AddressComponents; + form: AddressForm; + errorNumber: number; + errorCount: number; + nextOrContinue: () => void; +} + +export function ReviewRecommendedAddress({ + enteredAddress, + recommendedAddress, + form, + errorNumber, + errorCount, + nextOrContinue, +}: ReviewRecommendedAddressProps) { + const useRecommendedRef = useRef(null); + const enteredAddressRadioButtonId = useId(); + const recommendedAddressRadioButtonId = useId(); + + const confirmChoice = () => { + if (useRecommendedRef.current?.checked) { + form.fields.streetLine1.setValue(recommendedAddress.streetLine1.value); + form.fields.streetLine2.setValue( + recommendedAddress.streetLine2 ? + recommendedAddress.streetLine2.value + : '', + ); + form.fields.city.setValue(recommendedAddress.city.value); + form.fields.zip.setValue(recommendedAddress.zip.value); + form.fields.state.setValue(recommendedAddress.state.value); + } + + nextOrContinue(); + }; + + return ( +
+

Confirm Address

+

+ Would you like to use the recommended address? +

+

+ {errorNumber} / {errorCount} +

+ +
+ + +
+ +
+ + +
+ + +
+ ); +} diff --git a/src/app/register/addresses/address-confirmation-modal/review-recommended-address/styles.module.scss b/src/app/register/addresses/address-confirmation-modal/review-recommended-address/styles.module.scss new file mode 100644 index 00000000..39cbecfb --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/review-recommended-address/styles.module.scss @@ -0,0 +1,81 @@ +@use '../../../../../styles/partials' as *; + +.container { + max-width: 100%; +} + +.title { + @extend .b1; + margin-bottom: 5px; +} + +.caption { + @extend .b2; + margin-bottom: 5px; +} + +.error_number_of_count { + @extend .b2; +} + +.radio_group { + display: flex; + align-items: flex-start; + margin-top: 30px; + max-width: 100%; +} + +$radio-diameter: 16px; +$radio-margin: 15px; + +.radio { + position: relative; + margin-left: 0; + margin-right: $radio-margin; + min-width: $radio-diameter; + max-width: $radio-diameter; + min-height: $radio-diameter; + max-height: $radio-diameter; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +@supports (appearance: none) or (-webkit-appearance: none) or + (-moz-appearance: none) { + .radio::before { + content: ''; + position: absolute; + top: 0; + left: 0; + min-width: $radio-diameter; + max-width: $radio-diameter; + min-height: $radio-diameter; + max-height: $radio-diameter; + border: 2px solid $color-black-text; + border-radius: 50%; + } + + .radio:checked::after { + content: ''; + position: absolute; + top: 4px; + left: 4px; + min-width: $radio-diameter - 4px; + max-width: $radio-diameter - 4px; + min-height: $radio-diameter - 4px; + max-height: $radio-diameter - 4px; + background-color: $color-black-text; + border-radius: 50%; + } +} + +.label { + text-align: left; + max-width: 100%; +} + +.address { + max-width: calc(100% - $radio-diameter - $radio-margin); + overflow-wrap: break-word; +} diff --git a/src/app/register/addresses/address-confirmation-modal/types/address-form.ts b/src/app/register/addresses/address-confirmation-modal/types/address-form.ts new file mode 100644 index 00000000..832c253d --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/types/address-form.ts @@ -0,0 +1,8 @@ +import { HomeAddressForm } from '../../home-address/home-address-form'; +import { MailingAddressForm } from '../../mailing-address/mailing-address-form'; +import { PreviousAddressForm } from '../../previous-address/previous-address-form'; + +export type AddressForm = + | InstanceType + | InstanceType + | InstanceType; diff --git a/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/index.tsx b/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/index.tsx new file mode 100644 index 00000000..fe393a90 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/index.tsx @@ -0,0 +1 @@ +export { UnconfirmedComponents } from './unconfirmed-components'; diff --git a/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/styles.module.scss b/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/styles.module.scss new file mode 100644 index 00000000..04308c93 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/styles.module.scss @@ -0,0 +1,36 @@ +@use '../../../../../styles/partials' as *; + +.container { + max-width: 100%; +} + +.title { + @extend .b1; + margin-bottom: 5px; +} + +.caption { + @extend .b2; + margin-bottom: 5px; +} + +.emphasized { + @extend .b1; + text-decoration: underline; +} + +.error_number_of_count { + @extend .b2; + margin-bottom: 30px; +} + +.subtitle { + @extend .b3; + margin-bottom: 5px; +} + +.address { + max-width: 100%; + overflow-wrap: break-word; + margin-bottom: $spacing-lg; +} diff --git a/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/unconfirmed-components.tsx b/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/unconfirmed-components.tsx new file mode 100644 index 00000000..89537036 --- /dev/null +++ b/src/app/register/addresses/address-confirmation-modal/unconfirmed-components/unconfirmed-components.tsx @@ -0,0 +1,56 @@ +import { FormattedAddress } from '../formatted-address'; +import { Button } from '@/components/utils/button'; +import type { AddressComponents } from '@/model/types/addresses/address-components'; +import styles from './styles.module.scss'; + +interface UnconfirmedComponentsProps { + unconfirmedComponents: AddressComponents; + errorNumber: number; + errorCount: number; + returnToEditing: () => void; + nextOrContinue: () => void; +} + +export function UnconfirmedComponents({ + unconfirmedComponents, + errorNumber, + errorCount, + returnToEditing, + nextOrContinue, +}: UnconfirmedComponentsProps) { + return ( +
+

We couldn't confirm the address.

+

+ Please review the underlined{' '} + part of the address. +

+

+ {errorNumber} / {errorCount} +

+

What you entered

+ + + +
+ ); +} diff --git a/src/app/register/addresses/addresses-form.ts b/src/app/register/addresses/addresses-form.ts new file mode 100644 index 00000000..0cc2331d --- /dev/null +++ b/src/app/register/addresses/addresses-form.ts @@ -0,0 +1,24 @@ +import { SubFormTemplate, FormFactory, type FieldOfType } from 'fully-formed'; +import { HomeAddressForm } from './home-address/home-address-form'; +import { MailingAddressForm } from './mailing-address/mailing-address-form'; +import { PreviousAddressForm } from './previous-address/previous-address-form'; + +export const AddressesForm = FormFactory.createSubForm( + class AddressesTemplate extends SubFormTemplate { + public readonly name = 'addresses'; + public readonly fields: [ + InstanceType, + InstanceType, + InstanceType, + ]; + + public constructor(externalZipCodeField: FieldOfType) { + super(); + this.fields = [ + new HomeAddressForm(externalZipCodeField), + new MailingAddressForm(), + new PreviousAddressForm(), + ]; + } + }, +); diff --git a/src/app/register/addresses/addresses.tsx b/src/app/register/addresses/addresses.tsx new file mode 100644 index 00000000..928fb93f --- /dev/null +++ b/src/app/register/addresses/addresses.tsx @@ -0,0 +1,117 @@ +'use client'; +import { useState } from 'react'; +import { useExclude, ValidityUtils } from 'fully-formed'; +import { useRouter } from 'next/navigation'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { useScrollToTop } from '@/hooks/use-scroll-to-top'; +import { usePrefetchOtherDetailsWithStateAndZip } from './hooks/use-prefetch-other-details-with-state-and-zip'; +import { VoterRegistrationContext } from '../voter-registration-context'; +import { VoterRegistrationPathnames } from '../constants/voter-registration-pathnames'; +import { HomeAddress } from './home-address'; +import { Checkbox } from '@/components/form-components/checkbox'; +import { ExcludableContent } from '@/components/form-components/excludable-content/excludable-content'; +import { MailingAddress } from './mailing-address'; +import { PreviousAddress } from './previous-address'; +import { Button } from '@/components/utils/button'; +import { LoadingWheel } from '@/components/utils/loading-wheel'; +import { AddressConfirmationModal } from './address-confirmation-modal'; +import { getFirstNonValidInputId } from './utils/get-first-nonvalid-input-id'; +import { focusOnElementById } from '@/utils/client/focus-on-element-by-id'; +import { validateAddresses } from './utils/validate-addresses'; +import { applyCautionValidityToFormFields } from './utils/apply-caution-validity-to-form-fields'; +import type { FormEventHandler } from 'react'; +import type { AddressErrors } from '@/model/types/addresses/address-errors'; +import styles from './styles.module.scss'; + +export function Addresses() { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'Addresses', + ); + const addressesForm = voterRegistrationForm.fields.addresses; + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState([]); + + useScrollToTop(); + usePrefetchOtherDetailsWithStateAndZip(addressesForm.fields.homeAddress); + + const onSubmit: FormEventHandler = async e => { + e.preventDefault(); + if (isLoading) return; + + addressesForm.setSubmitted(); + + if (!ValidityUtils.isValidOrCaution(addressesForm)) { + const firstNonValidInputId = getFirstNonValidInputId(addressesForm); + firstNonValidInputId && focusOnElementById(firstNonValidInputId); + return; + } + + setIsLoading(true); + const errors = await validateAddresses(addressesForm.state.value); + + if (errors.length) { + setIsLoading(false); + setErrors(errors); + applyCautionValidityToFormFields(addressesForm, errors); + } else { + router.push( + VoterRegistrationPathnames.OTHER_DETAILS + + `?state=${addressesForm.state.value.homeAddress.state}` + + `&zip=${addressesForm.state.value.homeAddress.zip}`, + ); + } + }; + + const returnToEditing = () => { + setErrors([]); + const firstNonValidInputId = getFirstNonValidInputId(addressesForm); + firstNonValidInputId && focusOnElementById(firstNonValidInputId); + }; + + return ( +
+ {isLoading && } + + { + addressesForm.fields.mailingAddress.setExclude(!e.target.checked); + }} + labelContent="I get my mail at a different address from the one above" + name="hasMailingAddress" + containerClassName={ + useExclude(addressesForm.fields.mailingAddress) ? + styles.has_mailing_address_unchecked + : styles.has_mailing_address_checked + } + /> + + + + { + addressesForm.fields.previousAddress.setExclude(!e.target.checked); + }} + labelContent="I've changed my address since the last time I registered to vote" + name="hasPreviousAddress" + containerClassName="mb_md" + /> + + + + + {!!errors.length && ( + + )} + + ); +} diff --git a/src/app/register/addresses/home-address/home-address-form.ts b/src/app/register/addresses/home-address/home-address-form.ts new file mode 100644 index 00000000..7735a11c --- /dev/null +++ b/src/app/register/addresses/home-address/home-address-form.ts @@ -0,0 +1,121 @@ +import { + FormFactory, + SubFormTemplate, + PersistentField, + PersistentControlledField, + StringValidators, + ValidityUtils, + type NonTransientField, + type FieldOfType, +} from 'fully-formed'; +import zipState from 'zip-state'; +import { ZipCodeValidator } from '../../utils/zip-code-validator'; +import { PhoneValidator } from '../../utils/phone-validator'; +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; + +export const HomeAddressForm = FormFactory.createSubForm( + class HomeAddressTemplate extends SubFormTemplate { + public readonly name = 'homeAddress'; + public readonly autoTrim = true; + public readonly fields: [ + NonTransientField<'streetLine1', string>, + NonTransientField<'streetLine2', string>, + NonTransientField<'city', string>, + NonTransientField<'zip', string>, + NonTransientField<'state', string>, + NonTransientField<'phone', string>, + NonTransientField<'phoneType', string>, + ]; + + private readonly key = 'addresses.home'; + + public constructor(externalZipCodeField: FieldOfType) { + super(); + + const zip = new PersistentControlledField({ + name: 'zip', + key: this.key + '.zip', + controller: externalZipCodeField, + initFn: controllerState => { + return controllerState.value; + }, + controlFn: controllerState => controllerState.value, + validators: [new ZipCodeValidator()], + }); + + const state = new PersistentControlledField({ + name: 'state', + controller: zip, + key: this.key + '.state', + initFn: controllerState => { + if (!ValidityUtils.isValidOrCaution(controllerState)) { + return 'AL'; + } + + const state = zipState(controllerState.value); + if (!state || !Object.values(US_STATE_ABBREVIATIONS).includes(state)) + return 'AL'; + + return state; + }, + controlFn: controllerState => { + if ( + !ValidityUtils.isValidOrCaution(controllerState) || + !controllerState.didPropertyChange('value') + ) + return; + + const state = zipState(controllerState.value.trim()); + if ( + !state || + !Object.values(US_STATE_ABBREVIATIONS).includes(state) + ) { + return; + } + return state; + }, + }); + + this.fields = [ + new PersistentField({ + name: 'streetLine1', + key: this.key + '.streetLine1', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your street address.', + }), + ], + }), + new PersistentField({ + name: 'streetLine2', + key: this.key + '.streetLine2', + defaultValue: '', + }), + new PersistentField({ + name: 'city', + key: this.key + '.city', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your city.', + }), + ], + }), + zip, + state, + new PersistentField({ + name: 'phone', + key: this.key + '.phone', + defaultValue: '', + validators: [new PhoneValidator()], + }), + new PersistentField({ + name: 'phoneType', + key: this.key + '.phoneType', + defaultValue: 'Mobile', + }), + ]; + } + }, +); diff --git a/src/app/register/addresses/home-address/home-address.tsx b/src/app/register/addresses/home-address/home-address.tsx new file mode 100644 index 00000000..41c22c67 --- /dev/null +++ b/src/app/register/addresses/home-address/home-address.tsx @@ -0,0 +1,130 @@ +'use client'; +import Image from 'next/image'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { useHasFieldWithCautionValidity } from '../hooks/use-has-field-with-caution-validity'; +import { VoterRegistrationContext } from '../../voter-registration-context'; +import { MoreInfo } from '@/components/utils/more-info'; +import { InputGroup } from '@/components/form-components/input-group'; +import { Select } from '@/components/form-components/select'; +import { PhoneInputGroup } from '@/components/form-components/phone-input-group'; +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; +import warningIconDark from '@/../public/static/images/components/shared/warning-icon-dark.svg'; +import styles from './styles.module.scss'; + +export function HomeAddress() { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'HomeAddress', + ); + const form = voterRegistrationForm.fields.addresses.fields.homeAddress; + const displayWarningMessage = useHasFieldWithCautionValidity(form); + + return ( +
+ + Home Address + + Provide your home address. Do not put your mailing address here if + it's different from your home address. Do not use a PO Box or + rural route without a box number. If you live in a rural area but + don't have a street address or have no address, you can show + where you live on a map later on the printed form. +

+ } + className={styles.more_info_button} + /> +
+ {displayWarningMessage && ( +

+ Please double-check fields marked with a{' '} + Warning icon +

+ )} + + + +
+ + +
+
+ ); +} diff --git a/src/app/register/addresses/home-address/index.tsx b/src/app/register/addresses/home-address/index.tsx new file mode 100644 index 00000000..729df5e9 --- /dev/null +++ b/src/app/register/addresses/home-address/index.tsx @@ -0,0 +1 @@ +export { HomeAddress } from './home-address'; diff --git a/src/app/register/addresses/home-address/styles.module.scss b/src/app/register/addresses/home-address/styles.module.scss new file mode 100644 index 00000000..bced20a6 --- /dev/null +++ b/src/app/register/addresses/home-address/styles.module.scss @@ -0,0 +1,57 @@ +@use '../../../../styles/partials' as *; + +.fieldset { + margin-bottom: 16px; +} + +.legend { + @extend .h2; + display: flex; + align-items: center; + margin-bottom: $spacing-sm; +} + +.more_info_button { + margin-left: 9px; +} + +.input_row_margin { + margin-bottom: 10px; +} + +.input_row { + @extend .input_row_margin; + display: flex; + justify-content: space-between; +} + +.zip { + flex: 1; + margin-right: 39px; +} + +.state { + width: 70px !important; + margin-top: 17px; +} + +.phone_number { + flex: 1; + margin-right: 29px; +} + +.phone_type { + margin-top: 17px; + width: 100px !important; +} + +.warning_message { + @extend .b6; + color: $color-review-dark; + margin-bottom: 16px; +} + +.warning_icon { + height: 14px; + width: 16.55px; +} diff --git a/src/app/register/addresses/hooks/use-has-field-with-caution-validity.ts b/src/app/register/addresses/hooks/use-has-field-with-caution-validity.ts new file mode 100644 index 00000000..9bc801bd --- /dev/null +++ b/src/app/register/addresses/hooks/use-has-field-with-caution-validity.ts @@ -0,0 +1,7 @@ +import { useMultiPipe, ValidityUtils, type IForm } from 'fully-formed'; + +export function useHasFieldWithCautionValidity(form: IForm) { + return useMultiPipe(Object.values(form.fields), states => { + return states.some(state => ValidityUtils.isCaution(state)); + }); +} diff --git a/src/app/register/addresses/hooks/use-prefetch-other-details-with-state-and-zip.ts b/src/app/register/addresses/hooks/use-prefetch-other-details-with-state-and-zip.ts new file mode 100644 index 00000000..1331bed1 --- /dev/null +++ b/src/app/register/addresses/hooks/use-prefetch-other-details-with-state-and-zip.ts @@ -0,0 +1,69 @@ +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { HomeAddressForm } from '../home-address/home-address-form'; +import { ValidityUtils } from 'fully-formed'; +import { VoterRegistrationPathnames } from '../../constants/voter-registration-pathnames'; + +/** + * Prefetches the JavaScript for the other details page when the value of either + * the state field of the home address form or the zip field of the same form + * changes and the form is not invalid. + * + * This ensures that the correct political party options are rendered by the + * other details page while preventing a flicker when the user advances to + * the page. + * + * @param homeAddressForm - An instance of {@link HomeAddressForm}. + */ +export function usePrefetchOtherDetailsWithStateAndZip( + homeAddressForm: InstanceType, +) { + const router = useRouter(); + + useEffect(() => { + if ( + ValidityUtils.isValidOrCaution(homeAddressForm.fields.state) && + ValidityUtils.isValidOrCaution(homeAddressForm.fields.zip) + ) { + router.prefetch( + VoterRegistrationPathnames.OTHER_DETAILS + + `?state=${homeAddressForm.state.value.state}&zip=${homeAddressForm.state.value.zip}`, + ); + } + + const usStateSubscription = homeAddressForm.fields.state.subscribeToState( + ({ value, validity, didPropertyChange }) => { + if ( + ValidityUtils.isValidOrCaution(validity) && + ValidityUtils.isValidOrCaution(homeAddressForm.fields.zip) && + didPropertyChange('value') + ) { + router.prefetch( + VoterRegistrationPathnames.OTHER_DETAILS + + `?state=${value}&zip=${homeAddressForm.state.value.zip}`, + ); + } + }, + ); + + const zipSubscription = homeAddressForm.fields.zip.subscribeToState( + ({ value, validity, didPropertyChange }) => { + if ( + ValidityUtils.isValidOrCaution(validity) && + ValidityUtils.isValidOrCaution(homeAddressForm.fields.state) && + didPropertyChange('value') + ) { + router.prefetch( + VoterRegistrationPathnames.OTHER_DETAILS + + `?state=${homeAddressForm.state.value.state}&zip=${value}`, + ); + } + }, + ); + + return () => { + usStateSubscription.unsubscribe(); + zipSubscription.unsubscribe(); + }; + }, [homeAddressForm, router]); +} diff --git a/src/app/register/addresses/mailing-address/index.tsx b/src/app/register/addresses/mailing-address/index.tsx new file mode 100644 index 00000000..d465bf61 --- /dev/null +++ b/src/app/register/addresses/mailing-address/index.tsx @@ -0,0 +1 @@ +export { MailingAddress } from './mailing-address'; diff --git a/src/app/register/addresses/mailing-address/mailing-address-form.ts b/src/app/register/addresses/mailing-address/mailing-address-form.ts new file mode 100644 index 00000000..69434a96 --- /dev/null +++ b/src/app/register/addresses/mailing-address/mailing-address-form.ts @@ -0,0 +1,95 @@ +import { + PersistentExcludableSubFormTemplate, + PersistentField, + PersistentControlledField, + StringValidators, + NonTransientField, + FormFactory, + ValidityUtils, +} from 'fully-formed'; +import zipState from 'zip-state'; +import { ZipCodeValidator } from '../../utils/zip-code-validator'; +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; + +export const MailingAddressForm = FormFactory.createPersistentExcludableSubForm( + class MailingAddressTemplate extends PersistentExcludableSubFormTemplate { + public readonly name = 'mailingAddress'; + public readonly key = 'addresses.mailing'; + public readonly autoTrim = true; + + public readonly fields: [ + NonTransientField<'streetLine1', string>, + NonTransientField<'streetLine2', string>, + NonTransientField<'city', string>, + NonTransientField<'zip', string>, + NonTransientField<'state', string>, + ]; + + public readonly excludeByDefault = true; + + public constructor() { + super(); + const zip = new PersistentField({ + name: 'zip', + key: this.key + '.zip', + id: 'mailing-zip', + defaultValue: '', + validators: [new ZipCodeValidator()], + }); + + const state = new PersistentControlledField({ + name: 'state', + id: 'mailing-state', + key: this.key + '.state', + controller: zip, + initFn: () => 'AL', + controlFn: controllerState => { + if ( + !ValidityUtils.isValidOrCaution(controllerState) || + !controllerState.didPropertyChange('value') + ) + return; + + const state = zipState(controllerState.value.trim()); + if (!state || !Object.values(US_STATE_ABBREVIATIONS).includes(state)) + return; + + return state; + }, + }); + + this.fields = [ + new PersistentField({ + name: 'streetLine1', + id: 'mailing-street-line-1', + key: this.key + '.streetLine1', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your street address.', + }), + ], + }), + new PersistentField({ + name: 'streetLine2', + id: 'mailing-street-line-2', + key: this.key + '.streetLine2', + defaultValue: '', + }), + new PersistentField({ + name: 'city', + id: 'mailing-city', + key: this.key + '.city', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your city.', + }), + ], + }), + zip, + state, + ]; + } + }, +); diff --git a/src/app/register/addresses/mailing-address/mailing-address.tsx b/src/app/register/addresses/mailing-address/mailing-address.tsx new file mode 100644 index 00000000..9f99e804 --- /dev/null +++ b/src/app/register/addresses/mailing-address/mailing-address.tsx @@ -0,0 +1,79 @@ +'use client'; +import Image from 'next/image'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { useHasFieldWithCautionValidity } from '../hooks/use-has-field-with-caution-validity'; +import { VoterRegistrationContext } from '../../voter-registration-context'; +import { InputGroup } from '@/components/form-components/input-group'; +import { Select } from '@/components/form-components/select'; +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; +import warningIconDark from '@/../public/static/images/components/shared/warning-icon-dark.svg'; +import styles from './styles.module.scss'; + +export function MailingAddress() { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'MailingAddress', + ); + const form = voterRegistrationForm.fields.addresses.fields.mailingAddress; + const displayWarningMessage = useHasFieldWithCautionValidity(form); + + return ( +
+ Mailing Address + {displayWarningMessage && ( +

+ Please double-check fields marked with a{' '} + Warning icon +

+ )} + + + +
+ + ({ + text: abbr, + value: abbr, + }))} + className={styles.state} + aria-required + /> +
+
+ ); +} diff --git a/src/app/register/addresses/previous-address/styles.module.scss b/src/app/register/addresses/previous-address/styles.module.scss new file mode 100644 index 00000000..5cbf17ab --- /dev/null +++ b/src/app/register/addresses/previous-address/styles.module.scss @@ -0,0 +1,43 @@ +@use '../../../../styles/partials' as *; + +.fieldset { + margin-bottom: $spacing-md; +} + +.legend { + @extend .h2; + display: flex; + align-items: center; + margin-bottom: $spacing-sm; +} + +.input_row_margin { + margin-bottom: 10px; +} + +.input_row { + @extend .input_row_margin; + display: flex; + justify-content: space-between; +} + +.zip { + flex: 1; + margin-right: 39px; +} + +.state { + width: 70px !important; + margin-top: 17px; +} + +.warning_message { + @extend .b6; + color: $color-review-dark; + margin-bottom: 16px; +} + +.warning_icon { + height: 14px; + width: 16.55px; +} diff --git a/src/app/register/addresses/styles.module.scss b/src/app/register/addresses/styles.module.scss new file mode 100644 index 00000000..98cd899d --- /dev/null +++ b/src/app/register/addresses/styles.module.scss @@ -0,0 +1,9 @@ +@use '../../../styles/partials' as *; + +.has_mailing_address_unchecked { + margin-bottom: 20px; +} + +.has_mailing_address_checked { + margin-bottom: $spacing-md; +} diff --git a/src/app/register/addresses/utils/apply-caution-validity-to-form-fields.ts b/src/app/register/addresses/utils/apply-caution-validity-to-form-fields.ts new file mode 100644 index 00000000..e452ce88 --- /dev/null +++ b/src/app/register/addresses/utils/apply-caution-validity-to-form-fields.ts @@ -0,0 +1,47 @@ +import { Validity } from 'fully-formed'; +import { AddressesForm } from '../addresses-form'; +import { AddressErrorTypes } from '@/model/types/addresses/address-error-types'; +import type { AddressErrors } from '@/model/types/addresses/address-errors'; +import type { AddressComponent } from '@/model/types/addresses/address-component'; + +export function applyCautionValidityToFormFields( + form: InstanceType, + errors: AddressErrors[], +) { + errors.forEach(error => { + if (error.type === AddressErrorTypes.MissingSubpremise) { + form.fields[error.form].fields.streetLine2.setValidityAndMessages( + Validity.Caution, + [], + ); + } else if (error.type === AddressErrorTypes.ReviewRecommendedAddress) { + for (const entry of Object.entries(error.enteredAddress)) { + const [fieldName, addressComponent] = entry as [ + keyof (typeof form.fields)[(typeof error)['form']]['fields'], + AddressComponent, + ]; + + if (addressComponent.hasIssue) { + form.fields[error.form].fields[fieldName].setValidityAndMessages( + Validity.Caution, + [], + ); + } + } + } else if (error.type === AddressErrorTypes.UnconfirmedComponents) { + for (const entry of Object.entries(error.unconfirmedAddressComponents)) { + const [fieldName, addressComponent] = entry as [ + keyof (typeof form.fields)[(typeof error)['form']]['fields'], + AddressComponent, + ]; + + if (addressComponent.hasIssue) { + form.fields[error.form].fields[fieldName].setValidityAndMessages( + Validity.Caution, + [], + ); + } + } + } + }); +} diff --git a/src/app/register/addresses/utils/get-first-nonvalid-input-id.ts b/src/app/register/addresses/utils/get-first-nonvalid-input-id.ts new file mode 100644 index 00000000..2cdae2d9 --- /dev/null +++ b/src/app/register/addresses/utils/get-first-nonvalid-input-id.ts @@ -0,0 +1,82 @@ +import { ValidityUtils } from 'fully-formed'; +import type { AddressesForm } from '../addresses-form'; +import type { HomeAddressForm } from '../home-address/home-address-form'; +import type { AddressForm } from '../address-confirmation-modal/types/address-form'; + +export function getFirstNonValidInputId( + form: InstanceType, +): string | null { + const { homeAddress, mailingAddress, previousAddress } = form.fields; + + const firstNonValidHomeAddressFieldId = + getFirstNonValidHomeAddressFieldId(homeAddress); + if (firstNonValidHomeAddressFieldId) return firstNonValidHomeAddressFieldId; + + const firstNonValidMailingAddressId = + !mailingAddress.state.exclude && + getFirstNonValidAddressFormFieldId(mailingAddress); + if (firstNonValidMailingAddressId) return firstNonValidMailingAddressId; + + const firstNonValidPreviousAddressId = + !previousAddress.state.exclude && + getFirstNonValidAddressFormFieldId(previousAddress); + if (firstNonValidPreviousAddressId) return firstNonValidPreviousAddressId; + + return null; +} + +function getFirstNonValidHomeAddressFieldId( + homeAddressForm: InstanceType, +): string | undefined { + if (!ValidityUtils.isValid(homeAddressForm.fields.streetLine1)) { + return homeAddressForm.fields.streetLine1.id; + } + + if (!ValidityUtils.isValid(homeAddressForm.fields.streetLine2)) { + return homeAddressForm.fields.streetLine2.id; + } + + if (!ValidityUtils.isValid(homeAddressForm.fields.city)) { + return homeAddressForm.fields.city.id; + } + + if (!ValidityUtils.isValid(homeAddressForm.fields.zip)) { + return homeAddressForm.fields.zip.id; + } + + if (!ValidityUtils.isValid(homeAddressForm.fields.state)) { + return homeAddressForm.fields.state.id; + } + + if (!ValidityUtils.isValid(homeAddressForm.fields.phone)) { + return homeAddressForm.fields.phone.id; + } + + if (!ValidityUtils.isValid(homeAddressForm.fields.phoneType)) { + return homeAddressForm.fields.phoneType.id; + } +} + +function getFirstNonValidAddressFormFieldId( + addressForm: AddressForm, +): string | undefined { + if (!ValidityUtils.isValid(addressForm.fields.streetLine1)) { + return addressForm.fields.streetLine1.id; + } + + if (!ValidityUtils.isValid(addressForm.fields.streetLine2)) { + return addressForm.fields.streetLine2.id; + } + + if (!ValidityUtils.isValid(addressForm.fields.city)) { + return addressForm.fields.city.id; + } + + if (!ValidityUtils.isValid(addressForm.fields.zip)) { + return addressForm.fields.zip.id; + } + + if (!ValidityUtils.isValid(addressForm.fields.state)) { + return addressForm.fields.state.id; + } +} diff --git a/src/app/register/addresses/utils/validate-addresses.ts b/src/app/register/addresses/utils/validate-addresses.ts new file mode 100644 index 00000000..8010b1ca --- /dev/null +++ b/src/app/register/addresses/utils/validate-addresses.ts @@ -0,0 +1,37 @@ +import { AddressErrorTypes } from '@/model/types/addresses/address-error-types'; +import type { ValidateAddressesParams } from '@/services/server/validate-addresses/validate-addresses'; +import type { AddressErrors } from '@/model/types/addresses/address-errors'; + +interface ValidationResponse { + result: { + errors: AddressErrors[]; + }; +} + +export async function validateAddresses( + params: ValidateAddressesParams, +): Promise { + try { + const response = await fetch('/api/validate-addresses', { + method: 'POST', + body: JSON.stringify(params), + }); + + if (!response.ok) { + return [ + { + type: AddressErrorTypes.ValidationFailed, + }, + ]; + } + + const body = (await response.json()) as ValidationResponse; + return body.result.errors; + } catch (e) { + return [ + { + type: AddressErrorTypes.ValidationFailed, + }, + ]; + } +} diff --git a/src/app/register/constants/voter-registration-pathnames.ts b/src/app/register/constants/voter-registration-pathnames.ts new file mode 100644 index 00000000..d09146bb --- /dev/null +++ b/src/app/register/constants/voter-registration-pathnames.ts @@ -0,0 +1,28 @@ +export class VoterRegistrationPathnames { + private static readonly pathNames = new Map([ + ['ELIGIBILITY', '/register/eligibility'], + ['NAMES', '/register/names'], + ['ADDRESSES', '/register/addresses'], + ['OTHER_DETAILS', '/register/other-details'], + ]); + + public static get ELIGIBILITY() { + return this.pathNames.get('ELIGIBILITY')!; + } + + public static get NAMES() { + return this.pathNames.get('NAMES')!; + } + + public static get ADDRESSES() { + return this.pathNames.get('ADDRESSES')!; + } + + public static get OTHER_DETAILS() { + return this.pathNames.get('OTHER_DETAILS')!; + } + + public static getPathIndex(path: string) { + return Array.from(this.pathNames.values()).indexOf(path); + } +} diff --git a/src/app/register/eligibility/eligibility-form.ts b/src/app/register/eligibility/eligibility-form.ts new file mode 100644 index 00000000..06ce71da --- /dev/null +++ b/src/app/register/eligibility/eligibility-form.ts @@ -0,0 +1,126 @@ +import { + SubFormTemplate, + Adapter, + Field, + PersistentField, + FormFactory, + IAdapter, + IField, + Validator, + ValidityUtils, +} from 'fully-formed'; +import { ZipCodeValidator } from '../utils/zip-code-validator'; + +class EligibilityTemplate extends SubFormTemplate { + public readonly name = 'eligibility'; + public readonly autoTrim = { + include: ['email', 'zip'], + }; + + public readonly fields: [ + IField<'email', string, false>, + IField<'zip', string, false>, + IField<'dob', string, true>, + IField<'eighteenPlus', boolean, true>, + IField<'isCitizen', boolean, true>, + ]; + + public readonly adapters: [ + IAdapter<'dob', string>, + IAdapter<'eighteenPlus', string>, + IAdapter<'isCitizen', string>, + ]; + + private readonly key = 'eligibility'; + + public constructor(email: string) { + super(); + this.fields = [ + new Field({ + name: 'email', + defaultValue: email, + }), + new PersistentField({ + name: 'zip', + key: this.key + '.zip', + defaultValue: '', + validators: [new ZipCodeValidator()], + }), + new PersistentField({ + name: 'dob', + key: this.key + '.dob', + defaultValue: '', + validators: [ + new Validator({ + predicate: value => { + value = value.trim(); + const millis = new Date(value).getMilliseconds(); + return !Number.isNaN(millis); + }, + invalidMessage: 'Please enter a valid date.', + }), + ], + transient: true, + }), + new PersistentField({ + name: 'eighteenPlus', + key: this.key + '.eighteenPlus', + defaultValue: false, + transient: true, + validatorTemplates: [ + { + predicate: value => { + return value; + }, + invalidMessage: + 'You must be at least 18 years old by the next election to vote.', + }, + ], + }), + new PersistentField({ + name: 'isCitizen', + key: this.key + '.isCitizen', + defaultValue: false, + transient: true, + validatorTemplates: [ + { + predicate: value => { + return value; + }, + invalidMessage: 'You must be a US Citizen to vote.', + }, + ], + }), + ]; + + this.adapters = [ + new Adapter({ + name: 'dob', + source: this.fields[2], + adaptFn: ({ value, validity }) => { + if (!ValidityUtils.isValid(validity)) return ''; + + const [year, month, day] = value.trim().split('-'); + + return `${month}/${day}/${year}`; + }, + }), + new Adapter({ + name: 'eighteenPlus', + source: this.fields[3], + adaptFn: ({ value }) => { + return value ? 'yes' : ''; + }, + }), + new Adapter({ + name: 'isCitizen', + source: this.fields[4], + adaptFn: ({ value }) => { + return value ? 'yes' : ''; + }, + }), + ]; + } +} + +export const EligibilityForm = FormFactory.createSubForm(EligibilityTemplate); diff --git a/src/app/register/eligibility/eligibility.tsx b/src/app/register/eligibility/eligibility.tsx new file mode 100644 index 00000000..5a950a69 --- /dev/null +++ b/src/app/register/eligibility/eligibility.tsx @@ -0,0 +1,167 @@ +'use client'; +import { useState, type FormEventHandler } from 'react'; +import { useRouter } from 'next/navigation'; +import { ValidityUtils, usePipe, useValidity, useValue } from 'fully-formed'; +import zipState from 'zip-state'; +import { DateTime } from 'luxon'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { usePrefetch } from '@/hooks/use-prefetch'; +import { VoterRegistrationContext } from '../voter-registration-context'; +import { VoterRegistrationPathnames } from '../constants/voter-registration-pathnames'; +import { Label } from '@/components/form-components/label'; +import { Checkbox } from '@/components/form-components/checkbox'; +import { InputGroup } from '@/components/form-components/input-group'; +import { Messages } from '@/components/form-components/messages'; +import { Button } from '@/components/utils/button'; +import { PreregistrationInfoModal } from './preregistration-info-modal'; +import { NorthDakotaInfoModal } from './north-dakota-info-modal'; +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; +import { calculateAge } from './utils/calculate-age'; +import { focusOnElementById } from '@/utils/client/focus-on-element-by-id'; +import { getFirstNonValidInputId } from './utils/get-first-non-valid-input-id'; +import styles from './styles.module.scss'; + +export function Eligibility() { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'Eligibility', + ); + const eligibilityForm = voterRegistrationForm.fields.eligibility; + const [showPreregistrationInfoModal, setShowPreregistrationInfoModal] = + useState(false); + + const [showNorthDakotaInfoModal, setShowNorthDakotaInfoModal] = + useState(false); + + const router = useRouter(); + usePrefetch(VoterRegistrationPathnames.NAMES); + + const onSubmit: FormEventHandler = e => { + e.preventDefault(); + eligibilityForm.setSubmitted(); + + if (!ValidityUtils.isValid(eligibilityForm)) { + const firstNonValidInputId = getFirstNonValidInputId(eligibilityForm); + firstNonValidInputId && focusOnElementById(firstNonValidInputId); + return; + } + + if ( + zipState(eligibilityForm.fields.zip.state.value) === + US_STATE_ABBREVIATIONS.NORTH_DAKOTA + ) { + setShowNorthDakotaInfoModal(true); + } else if (calculateAge(eligibilityForm.fields.dob.state.value) < 18) { + setShowPreregistrationInfoModal(true); + } else { + router.push(VoterRegistrationPathnames.NAMES); + } + }; + + return ( +
+

+ Registering to vote is easy, and only takes a few minutes! +

+

Eligibility

+ + + + + { + eligibilityForm.fields.eighteenPlus.setValue(e.target.checked); + }} + labelContent="I will be 18 years-old or older by the next election.*" + /> + { + return !( + state.hasBeenBlurred || + state.hasBeenModified || + state.submitted + ); + })} + containerClassName={styles.eighteen_plus_checkbox_messages} + /> + { + eligibilityForm.fields.isCitizen.setValue(e.target.checked); + }} + labelContent="I am a US citizen.*" + /> + { + return !( + state.hasBeenBlurred || + state.hasBeenModified || + state.submitted + ); + })} + containerClassName={styles.is_citizen_checkbox_messages} + /> + + {showNorthDakotaInfoModal && ( + + )} + {showPreregistrationInfoModal && ( + + )} + + ); +} diff --git a/src/app/register/eligibility/north-dakota-info-modal/index.tsx b/src/app/register/eligibility/north-dakota-info-modal/index.tsx new file mode 100644 index 00000000..8fdffe93 --- /dev/null +++ b/src/app/register/eligibility/north-dakota-info-modal/index.tsx @@ -0,0 +1 @@ +export { NorthDakotaInfoModal } from './north-dakota-info-modal'; diff --git a/src/app/register/eligibility/north-dakota-info-modal/north-dakota-info-modal.tsx b/src/app/register/eligibility/north-dakota-info-modal/north-dakota-info-modal.tsx new file mode 100644 index 00000000..bbc78a52 --- /dev/null +++ b/src/app/register/eligibility/north-dakota-info-modal/north-dakota-info-modal.tsx @@ -0,0 +1,78 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Modal } from '@/components/utils/modal'; +import { Button } from '@/components/utils/button'; +import { VoterRegistrationPathnames } from '../../constants/voter-registration-pathnames'; +import type { Dispatch, SetStateAction } from 'react'; +import styles from './styles.module.scss'; + +interface NorthDakotaInfoModalProps { + showModal: boolean; + setShowModal: Dispatch>; +} + +/** + * A modal that displays information voting in North Dakota, as it is the only + * state in the US that does not require its residents to register in order to + * vote. + * + * @param props - {@link NorthDakotaInfoModalProps} + */ +export function NorthDakotaInfoModal({ + showModal, + setShowModal, +}: NorthDakotaInfoModalProps) { + const router = useRouter(); + const votingInNorthDakota = + 'https://www.sos.nd.gov/elections/voter/voting-north-dakota'; + const closeModal = () => setShowModal(false); + + return ( + +

+ Hey there! Looks like you're from North Dakota. +

+

+ North Dakota does not require voter registration. For more information + on voting in North Dakota, please see{' '} + + Voting in North Dakota + + . +

+
+ + +
+
+ ); +} diff --git a/src/app/register/eligibility/north-dakota-info-modal/styles.module.scss b/src/app/register/eligibility/north-dakota-info-modal/styles.module.scss new file mode 100644 index 00000000..e9ff1eb5 --- /dev/null +++ b/src/app/register/eligibility/north-dakota-info-modal/styles.module.scss @@ -0,0 +1,16 @@ +@use '../../../../styles/partials' as *; + +.title { + @extend .b1; + margin-bottom: 12px; +} + +.buttons_container { + display: flex; + flex-direction: column; + align-items: center; +} + +.button { + margin-top: $spacing-sm; +} diff --git a/src/app/register/eligibility/page.tsx b/src/app/register/eligibility/page.tsx new file mode 100644 index 00000000..d0865fd4 --- /dev/null +++ b/src/app/register/eligibility/page.tsx @@ -0,0 +1,16 @@ +import dynamic from 'next/dynamic'; + +/* + Render the eligibility form on the client side to prevent hydration errors + due to reading persistent form data from sessionStorage. +*/ +const Eligibility = dynamic( + () => import('./eligibility').then(module => module.Eligibility), + { + ssr: false, + }, +); + +export default function Page() { + return ; +} diff --git a/src/app/register/eligibility/preregistration-info-modal/index.tsx b/src/app/register/eligibility/preregistration-info-modal/index.tsx new file mode 100644 index 00000000..ddf48859 --- /dev/null +++ b/src/app/register/eligibility/preregistration-info-modal/index.tsx @@ -0,0 +1 @@ +export { PreregistrationInfoModal } from './preregistration-info-modal'; diff --git a/src/app/register/eligibility/preregistration-info-modal/preregistration-info-modal.tsx b/src/app/register/eligibility/preregistration-info-modal/preregistration-info-modal.tsx new file mode 100644 index 00000000..80bbad79 --- /dev/null +++ b/src/app/register/eligibility/preregistration-info-modal/preregistration-info-modal.tsx @@ -0,0 +1,70 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import { usePipe, type FieldOfType } from 'fully-formed'; +import { Modal } from '@/components/utils/modal'; +import { Button } from '@/components/utils/button'; +import zipState from 'zip-state'; +import { getPreregistrationInfo } from '../utils/get-preregistration-info'; +import { VoterRegistrationPathnames } from '../../constants/voter-registration-pathnames'; +import type { Dispatch, SetStateAction } from 'react'; +import styles from './styles.module.scss'; + +interface PreregistrationInfoModalProps { + zipCodeField: FieldOfType; + showModal: boolean; + setShowModal: Dispatch>; +} + +/** + * A modal that displays state-specific information about preregistering to vote. + * Intended to be displayed to users if they are under 18 years of age. + * + * @param props - {@link PreregistrationInfoModalProps} + */ +export function PreregistrationInfoModal({ + zipCodeField, + showModal, + setShowModal, +}: PreregistrationInfoModalProps) { + const router = useRouter(); + const state = usePipe(zipCodeField, ({ value }) => zipState(value)); + const preregistrationInformation = getPreregistrationInfo(state); + + return ( + setShowModal(false)} + > +

+ Hey there! +
+ Looks like you're not 18 yet. +

+

{preregistrationInformation}

+
+ + +
+
+ ); +} diff --git a/src/app/register/eligibility/preregistration-info-modal/styles.module.scss b/src/app/register/eligibility/preregistration-info-modal/styles.module.scss new file mode 100644 index 00000000..e9ff1eb5 --- /dev/null +++ b/src/app/register/eligibility/preregistration-info-modal/styles.module.scss @@ -0,0 +1,16 @@ +@use '../../../../styles/partials' as *; + +.title { + @extend .b1; + margin-bottom: 12px; +} + +.buttons_container { + display: flex; + flex-direction: column; + align-items: center; +} + +.button { + margin-top: $spacing-sm; +} diff --git a/src/app/register/eligibility/styles.module.scss b/src/app/register/eligibility/styles.module.scss new file mode 100644 index 00000000..00a54f2f --- /dev/null +++ b/src/app/register/eligibility/styles.module.scss @@ -0,0 +1,39 @@ +@use '../../../styles/partials' as *; + +.readonly_input { + @extend .b1; + border: none; + margin-bottom: $spacing-sm; + + &:focus { + outline: none; + } +} + +.zip_code { + margin-bottom: 10px; +} + +.modal_button_container { + display: flex; + flex-direction: column; + align-items: center; +} + +.modal_button { + @extend .btn_inverted; + @extend .btn_sm; + margin-top: 24px; +} + +.eighteen_plus_checkbox_messages { + min-height: $font-size-xxs + 5px; + margin-bottom: 20px; + margin-top: 4px; +} + +.is_citizen_checkbox_messages { + min-height: $font-size-xxs + 5px; + margin-bottom: $spacing-md; + margin-top: 4px; +} diff --git a/src/app/register/eligibility/utils/calculate-age.ts b/src/app/register/eligibility/utils/calculate-age.ts new file mode 100644 index 00000000..8b805204 --- /dev/null +++ b/src/app/register/eligibility/utils/calculate-age.ts @@ -0,0 +1,18 @@ +import { DateTime } from 'luxon'; + +/** + * Calculates the user's age based on their date of birth. + * + * @param dob - The user's date of birth. A string in the format yyyy-MM-dd. + * @returns The user's age. + */ +export function calculateAge(dob: string) { + const dateOfBirth = DateTime.fromISO(dob); + const today = DateTime.now(); + const age = Math.max( + 0, + /* istanbul ignore next */ + today.diff(dateOfBirth, 'years').toObject().years ?? 0, + ); + return age; +} diff --git a/src/app/register/eligibility/utils/get-first-non-valid-input-id.ts b/src/app/register/eligibility/utils/get-first-non-valid-input-id.ts new file mode 100644 index 00000000..155b588a --- /dev/null +++ b/src/app/register/eligibility/utils/get-first-non-valid-input-id.ts @@ -0,0 +1,24 @@ +import { ValidityUtils } from 'fully-formed'; +import type { EligibilityForm } from '../eligibility-form'; + +export function getFirstNonValidInputId( + eligibilityForm: InstanceType, +): string | null { + if (!ValidityUtils.isValid(eligibilityForm.fields.zip)) { + return eligibilityForm.fields.zip.id; + } + + if (!ValidityUtils.isValid(eligibilityForm.fields.dob)) { + return eligibilityForm.fields.dob.id; + } + + if (!ValidityUtils.isValid(eligibilityForm.fields.eighteenPlus)) { + return eligibilityForm.fields.eighteenPlus.id; + } + + if (!ValidityUtils.isValid(eligibilityForm.fields.isCitizen)) { + return eligibilityForm.fields.isCitizen.id; + } + + return null; +} diff --git a/src/app/register/eligibility/utils/get-preregistration-info.ts b/src/app/register/eligibility/utils/get-preregistration-info.ts new file mode 100644 index 00000000..5fc0c11e --- /dev/null +++ b/src/app/register/eligibility/utils/get-preregistration-info.ts @@ -0,0 +1,17 @@ +import { PREREGISTRATION_INFO_BY_STATE } from './preregistration-information-by-state'; + +/** + * Determines what (if any) preregistration message to show the user based on + * the state detected from their zip code. + * + * @param zipCode - The user's 5-digit ZIP code. + * @returns The preregistration message for the user's state or an empty string + * if none exists. + */ +export function getPreregistrationInfo(state: string | null) { + if (!state || !(state in PREREGISTRATION_INFO_BY_STATE)) { + return ''; + } + + return PREREGISTRATION_INFO_BY_STATE[state]; +} diff --git a/src/app/register/eligibility/utils/preregistration-information-by-state.ts b/src/app/register/eligibility/utils/preregistration-information-by-state.ts new file mode 100644 index 00000000..e41db2e3 --- /dev/null +++ b/src/app/register/eligibility/utils/preregistration-information-by-state.ts @@ -0,0 +1,116 @@ +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; + +/** + * Messages that can be displayed to the user if they are under 18 years old and + * attempt to register to vote. Each message describes the requirements for + * preregistering to vote for a given state. North Dakota is omitted because, + * as of the time of writing, residents of North Dakota do not need to register + * to vote in order to do so. + * + * @remarks + * This information was sourced from [Rock The Vote](https://www.rockthevote.org/how-to-vote/nationwide-voting-info/voter-pre-registration/). + * + * For more information on voting in North Dakota, please see [Voting in North Dakota](https://www.sos.nd.gov/elections/voter/voting-north-dakota). + */ +export const PREREGISTRATION_INFO_BY_STATE = { + [US_STATE_ABBREVIATIONS.ALABAMA]: + 'You must be at least 18 years old by Election Day to preregister to vote in Alabama.', + [US_STATE_ABBREVIATIONS.ALASKA]: + 'You must be at least 17 years old and within 90 days of your 18th birthday to preregister to vote in Alaska. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.ARIZONA]: + 'You must be at least 18 years old by the date of the next general election to preregister to vote in Arizona.', + [US_STATE_ABBREVIATIONS.ARKANSAS]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in Arkansas.', + [US_STATE_ABBREVIATIONS.CALIFORNIA]: + 'You must be at least 16 years old to preregister to vote in California. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.COLORADO]: + 'You must be at least 16 years old to preregister to vote in Colorado. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.CONNECTICUT]: + 'You must be at least 17 years old now and must be at least 18 years old by the date of the next election to preregister to vote in Connecticut.', + [US_STATE_ABBREVIATIONS.DELAWARE]: + 'You must be at least 18 years old by the date of the next general election to preregister to vote using this app in Delaware.', + [US_STATE_ABBREVIATIONS.DISTRICT_OF_COLUMBIA]: + 'You must be at least 16 years old to preregister to vote in the District of Columbia. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.FLORIDA]: + 'You must be at least 16 years old to preregister to vote in Florida. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.GEORGIA]: + 'You must be at least 17 ½ years old to preregister to vote in Georgia. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.HAWAII]: + 'You must be at least 16 years old to preregister to vote in Hawaii. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.IDAHO]: + 'You must be at least 18 years old by Election Day to preregister to vote in Idaho.', + [US_STATE_ABBREVIATIONS.ILLINOIS]: + 'You must be at least 18 years old by Election Day to preregister to vote using this app in Illinois.', + [US_STATE_ABBREVIATIONS.INDIANA]: + 'You must be at least 18 years old by the date of the next general or municipal election to preregister to vote in Indiana.', + [US_STATE_ABBREVIATIONS.IOWA]: + 'You must be at least 17 years old now and must be at least 18 years old by the date of the next election to preregister to vote in Iowa.', + [US_STATE_ABBREVIATIONS.KANSAS]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in Kansas.', + [US_STATE_ABBREVIATIONS.KENTUCKY]: + 'You must be at least 17 years old now and must be at least 18 years old by the date of the next election to preregister to vote in Kentucky.', + [US_STATE_ABBREVIATIONS.LOUISIANA]: + 'You must be at least 17 years old to preregister to vote in Lousiana. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.MAINE]: + 'You must be at least 16 years old to preregister to vote in Maine. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.MARYLAND]: + 'You must be at least 16 years old to preregister to vote in Maryland. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.MASSACHUSETTS]: + 'You must be at least 16 years old to preregister in Massachusetts. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.MICHIGAN]: + 'You must be at least 16 years old to preregister in Michigan. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.MINNESOTA]: + 'You must be at least 16 years old to preregister in Minnesota. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.MISSISSIPPI]: + 'You must be at least 18 years old by the date of the next General Election to preregister to vote in Mississippi.', + [US_STATE_ABBREVIATIONS.MISSOURI]: + 'You must be at least 17 ½ years old now and must be at least 18 years old by election day to preregister to vote in Missouri.', + [US_STATE_ABBREVIATIONS.MONTANA]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in Montana.', + [US_STATE_ABBREVIATIONS.NEBRASKA]: + 'You must be at least 17 years old now and must be 18 years old by the first Tuesday following the first Monday in November of this year to preregister to vote in Nebraska. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.NEVADA]: + 'You must be at least 17 years old to preregister to vote in Nevada. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.NEW_HAMPSHIRE]: + 'You must be at least 18 years old by Election Day to preregister to vote in New Hampshire.', + [US_STATE_ABBREVIATIONS.NEW_JERSEY]: + 'You must be at least 17 years old to preregister to vote in New Jersey. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.NEW_MEXICO]: + 'You must be at least 17 years old now and must be at least 18 years old by the date of the next general election to preregister to vote in New Mexico.', + [US_STATE_ABBREVIATIONS.NEW_YORK]: + 'You must be at least 16 years old to preregister to vote in New York. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.NORTH_CAROLINA]: + 'You must be at least 16 years old to preregister to vote in North Carolina. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.OHIO]: + 'You must be at least 18 years old by the date of the next general election to preregister to vote in Ohio.', + [US_STATE_ABBREVIATIONS.OKLAHOMA]: + 'You must be at least 17 ½ years old now and must be at least 18 years old by Election Day to preregister to vote in Oklahoma.', + [US_STATE_ABBREVIATIONS.OREGON]: + 'You must be at least 16 years old to preregister to vote in Oregon. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.PENNSYLVANIA]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in Pennsylvania.', + [US_STATE_ABBREVIATIONS.RHODE_ISLAND]: + 'You must be at least 16 years old to preregister to vote in Rhode Island. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.SOUTH_CAROLINA]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in South Carolina.', + [US_STATE_ABBREVIATIONS.SOUTH_DAKOTA]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in South Dakota.', + [US_STATE_ABBREVIATIONS.TENNESSEE]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in Tennessee.', + [US_STATE_ABBREVIATIONS.TEXAS]: + 'You must be at least 17 years and 10 months old now and must be 18 years old by the date of the next election to preregister to vote in Texas.', + [US_STATE_ABBREVIATIONS.UTAH]: + 'You must be at least 16 years old to preregister to vote in Utah. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.VERMONT]: + 'You must be at least 17 years old by the date of the next election to preregister to vote in Vermont.', + [US_STATE_ABBREVIATIONS.VIRGINIA]: + 'You must be at least 17 years old now and must be 18 years old by the date of the next general election to preregister to vote in Virginia.', + [US_STATE_ABBREVIATIONS.WASHINGTON]: + 'You must be at least 16 years old to preregister to vote in Washington. Please note you will need to be 18 by election day to vote.', + [US_STATE_ABBREVIATIONS.WEST_VIRGINIA]: + 'You must be at least 17 years old now and must be 18 years old by the date of the next general election to preregister to vote in West Virginia.', + [US_STATE_ABBREVIATIONS.WISCONSIN]: + 'You must be at least 18 years old by the date of the next election to preregister to vote in Wisconsin.', + [US_STATE_ABBREVIATIONS.WYOMING]: + 'You must be at least 18 years old by Election Day to preregister to vote in Wyoming.', +}; diff --git a/src/app/register/layout.tsx b/src/app/register/layout.tsx new file mode 100644 index 00000000..0dca1095 --- /dev/null +++ b/src/app/register/layout.tsx @@ -0,0 +1,36 @@ +'use client'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { UserContext } from '@/contexts/user-context'; +import { useForm } from 'fully-formed'; +import { useRedirectToFirstIncompletePage } from './utils/use-redirect-to-first-incomplete-page'; +import { VoterRegistrationForm } from './voter-registration-form'; +import { VoterRegistrationContext } from './voter-registration-context'; +import { PageContainer } from '@/components/utils/page-container'; +import { ProgressBar } from './progress-bar'; +import type { PropsWithChildren } from 'react'; +import styles from './styles.module.scss'; + +export default function VoterRegistrationLayout({ + children, +}: PropsWithChildren) { + const { user } = useContextSafely(UserContext, 'VoterRegistrationLayout'); + const voterRegistrationForm = useForm(new VoterRegistrationForm(user)); + + useRedirectToFirstIncompletePage(voterRegistrationForm); + + return ( + + +
+

+ Register to Vote +

+
+ +
+ {children} +
+
+
+ ); +} diff --git a/src/app/register/names/names-form.ts b/src/app/register/names/names-form.ts new file mode 100644 index 00000000..42c8db4c --- /dev/null +++ b/src/app/register/names/names-form.ts @@ -0,0 +1,10 @@ +import { YourNameForm } from './your-name/your-name-form'; +import { PreviousNameForm } from './previous-name/previous-name-form'; +import { SubFormTemplate, FormFactory } from 'fully-formed'; + +class NameTemplate extends SubFormTemplate { + public readonly name = 'names'; + public readonly fields = [new YourNameForm(), new PreviousNameForm()]; +} + +export const NamesForm = FormFactory.createSubForm(NameTemplate); diff --git a/src/app/register/names/names.tsx b/src/app/register/names/names.tsx new file mode 100644 index 00000000..ab4fae41 --- /dev/null +++ b/src/app/register/names/names.tsx @@ -0,0 +1,78 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import { ValidityUtils, useExclude } from 'fully-formed'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { usePrefetch } from '@/hooks/use-prefetch'; +import { useScrollToTop } from '@/hooks/use-scroll-to-top'; +import { VoterRegistrationContext } from '../voter-registration-context'; +import { VoterRegistrationPathnames } from '../constants/voter-registration-pathnames'; +import { YourName } from './your-name'; +import { ExcludableContent } from '@/components/form-components/excludable-content/excludable-content'; +import { PreviousName } from './previous-name'; +import { Checkbox } from '@/components/form-components/checkbox'; +import { MoreInfo } from '@/components/utils/more-info'; +import { Button } from '@/components/utils/button'; +import { getFirstNonValidInputId } from './utils/get-first-non-valid-input-id'; +import { focusOnElementById } from '@/utils/client/focus-on-element-by-id'; +import type { FormEventHandler } from 'react'; +import styles from './styles.module.scss'; + +export function Names() { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'Names', + ); + const namesForm = voterRegistrationForm.fields.names; + const router = useRouter(); + usePrefetch(VoterRegistrationPathnames.ADDRESSES); + useScrollToTop(); + + const onSubmit: FormEventHandler = e => { + e.preventDefault(); + namesForm.setSubmitted(); + + if (!ValidityUtils.isValid(namesForm)) { + const firstNonValidInputId = getFirstNonValidInputId(namesForm); + firstNonValidInputId && focusOnElementById(firstNonValidInputId); + return; + } + + router.push(VoterRegistrationPathnames.ADDRESSES); + }; + + return ( +
+ +
+ { + namesForm.fields.previousName.setExclude(!e.target.checked); + }} + labelContent="I've changed my name." + name="changedName" + containerClassName={styles.checkbox} + /> + + If you have changed your name since your last registration, check + this box and enter your previous name below. +

+ } + className={styles.more_info_button} + /> +
+ + + + + + ); +} diff --git a/src/app/register/names/page.tsx b/src/app/register/names/page.tsx new file mode 100644 index 00000000..9aea619e --- /dev/null +++ b/src/app/register/names/page.tsx @@ -0,0 +1,13 @@ +import dynamic from 'next/dynamic'; + +/* + Render the names form on the client side to prevent hydration errors + due to reading persistent form data from sessionStorage. +*/ +const Names = dynamic(() => import('./names').then(module => module.Names), { + ssr: false, +}); + +export default function Page() { + return ; +} diff --git a/src/app/register/names/previous-name/index.tsx b/src/app/register/names/previous-name/index.tsx new file mode 100644 index 00000000..f20ff627 --- /dev/null +++ b/src/app/register/names/previous-name/index.tsx @@ -0,0 +1 @@ +export { PreviousName } from './previous-name'; diff --git a/src/app/register/names/previous-name/previous-name-form.ts b/src/app/register/names/previous-name/previous-name-form.ts new file mode 100644 index 00000000..c94f1fe4 --- /dev/null +++ b/src/app/register/names/previous-name/previous-name-form.ts @@ -0,0 +1,65 @@ +import { + FormFactory, + PersistentExcludableSubFormTemplate, + PersistentField, + StringValidators, +} from 'fully-formed'; + +export const PreviousNameForm = FormFactory.createPersistentExcludableSubForm( + class PreviousNameTemplate extends PersistentExcludableSubFormTemplate { + public readonly name = 'previousName'; + public readonly key = 'names.previous'; + public readonly autoTrim = true; + public readonly excludeByDefault = true; + + public readonly fields = [ + new PersistentField({ + name: 'title', + id: 'previous-title', + key: this.key + '.title', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please select a title.', + }), + ], + }), + new PersistentField({ + name: 'first', + id: 'previous-first', + key: this.key + '.first', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your first name.', + trimBeforeValidation: true, + }), + ], + }), + new PersistentField({ + name: 'middle', + id: 'previous-middle', + key: this.key + '.middle', + defaultValue: '', + }), + new PersistentField({ + name: 'last', + id: 'previous-last', + key: this.key + '.last', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your last name.', + trimBeforeValidation: true, + }), + ], + }), + new PersistentField({ + name: 'suffix', + id: 'previous-suffix', + key: this.key + '.suffix', + defaultValue: '', + }), + ]; + }, +); diff --git a/src/app/register/names/previous-name/previous-name.tsx b/src/app/register/names/previous-name/previous-name.tsx new file mode 100644 index 00000000..65fc680d --- /dev/null +++ b/src/app/register/names/previous-name/previous-name.tsx @@ -0,0 +1,110 @@ +'use client'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { VoterRegistrationContext } from '../../voter-registration-context'; +import { Select } from '@/components/form-components/select'; +import { InputGroup } from '@/components/form-components/input-group'; +import styles from './styles.module.scss'; + +export function PreviousName() { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'YourName', + ); + const form = voterRegistrationForm.fields.names.fields.previousName; + + return ( +
+ Previous Name + +
+ ); +} diff --git a/src/app/register/names/previous-name/styles.module.scss b/src/app/register/names/previous-name/styles.module.scss new file mode 100644 index 00000000..2c124d8c --- /dev/null +++ b/src/app/register/names/previous-name/styles.module.scss @@ -0,0 +1,25 @@ +@use '../../../../styles/partials' as *; + +.fieldset { + margin-bottom: $spacing-md; +} + +.legend { + @extend .h2; + margin-bottom: $spacing-sm; + display: flex; + align-items: center; +} + +.more_info { + margin-left: 9px; +} + +.select { + width: 153px; + margin-bottom: 10px; +} + +.input { + margin-bottom: 10px; +} diff --git a/src/app/register/names/styles.module.scss b/src/app/register/names/styles.module.scss new file mode 100644 index 00000000..1797c5bd --- /dev/null +++ b/src/app/register/names/styles.module.scss @@ -0,0 +1,17 @@ +@use '../../../styles/partials' as *; + +.checkbox { + display: flex; + align-items: center; +} + +.checkbox_container { + display: flex; + align-items: center; + margin-bottom: $spacing-md; +} + +.more_info_button { + margin-left: 12px; + margin-top: 3px; +} diff --git a/src/app/register/names/utils/get-first-non-valid-input-id.ts b/src/app/register/names/utils/get-first-non-valid-input-id.ts new file mode 100644 index 00000000..b2c1070e --- /dev/null +++ b/src/app/register/names/utils/get-first-non-valid-input-id.ts @@ -0,0 +1,36 @@ +import { NamesForm } from '../names-form'; +import { ValidityUtils } from 'fully-formed'; + +export function getFirstNonValidInputId( + namesForm: InstanceType, +): string | null { + const { yourName, previousName } = namesForm.fields; + + if (!ValidityUtils.isValid(yourName.fields.title)) { + return yourName.fields.title.id; + } + + if (!ValidityUtils.isValid(yourName.fields.first)) { + return yourName.fields.first.id; + } + + if (!ValidityUtils.isValid(yourName.fields.last)) { + return yourName.fields.last.id; + } + + if (!previousName.state.exclude) { + if (!ValidityUtils.isValid(previousName.fields.title)) { + return previousName.fields.title.id; + } + + if (!ValidityUtils.isValid(previousName.fields.first)) { + return previousName.fields.first.id; + } + + if (!ValidityUtils.isValid(previousName.fields.last)) { + return previousName.fields.last.id; + } + } + + return null; +} diff --git a/src/app/register/names/your-name/index.tsx b/src/app/register/names/your-name/index.tsx new file mode 100644 index 00000000..c91f647f --- /dev/null +++ b/src/app/register/names/your-name/index.tsx @@ -0,0 +1 @@ +export { YourName } from './your-name'; diff --git a/src/app/register/names/your-name/styles.module.scss b/src/app/register/names/your-name/styles.module.scss new file mode 100644 index 00000000..0efc0a2d --- /dev/null +++ b/src/app/register/names/your-name/styles.module.scss @@ -0,0 +1,25 @@ +@use '../../../../styles/partials' as *; + +.fieldset { + margin-bottom: 16px; +} + +.legend { + @extend .h2; + margin-bottom: $spacing-sm; + display: flex; + align-items: center; +} + +.more_info { + margin-left: 9px; +} + +.select { + width: 153px; + margin-bottom: 10px; +} + +.input { + margin-bottom: 10px; +} diff --git a/src/app/register/names/your-name/your-name-form.ts b/src/app/register/names/your-name/your-name-form.ts new file mode 100644 index 00000000..b5ec738c --- /dev/null +++ b/src/app/register/names/your-name/your-name-form.ts @@ -0,0 +1,59 @@ +import { + FormFactory, + SubFormTemplate, + PersistentField, + StringValidators, +} from 'fully-formed'; + +export const YourNameForm = FormFactory.createSubForm( + class YourNameTemplate extends SubFormTemplate { + public readonly name = 'yourName'; + public readonly autoTrim = true; + private readonly key = 'names.yourName'; + + public readonly fields = [ + new PersistentField({ + name: 'title', + key: this.key + '.title', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please select a title.', + }), + ], + }), + new PersistentField({ + name: 'first', + key: this.key + '.first', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your first name.', + trimBeforeValidation: true, + }), + ], + }), + new PersistentField({ + name: 'middle', + key: this.key + '.middle', + defaultValue: '', + }), + new PersistentField({ + name: 'last', + key: this.key + '.last', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your last name.', + trimBeforeValidation: true, + }), + ], + }), + new PersistentField({ + name: 'suffix', + key: this.key + '.suffix', + defaultValue: '', + }), + ]; + }, +); diff --git a/src/app/register/names/your-name/your-name.tsx b/src/app/register/names/your-name/your-name.tsx new file mode 100644 index 00000000..f43a3f93 --- /dev/null +++ b/src/app/register/names/your-name/your-name.tsx @@ -0,0 +1,125 @@ +'use client'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { VoterRegistrationContext } from '../../voter-registration-context'; +import { Select } from '@/components/form-components/select'; +import { InputGroup } from '@/components/form-components/input-group'; +import { MoreInfo } from '@/components/utils/more-info'; +import styles from './styles.module.scss'; + +export function YourName() { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'YourName', + ); + const form = voterRegistrationForm.fields.names.fields.yourName; + + return ( +
+ + Your Name + + Provide your full name. Do not use nicknames or initials. If + you've changed your name, you will be asked for your previous + name below. +

+ } + className={styles.more_info} + /> +
+ +
+ ); +} diff --git a/src/app/register/other-details/get-first-non-valid-input-id.ts b/src/app/register/other-details/get-first-non-valid-input-id.ts new file mode 100644 index 00000000..e4d869bc --- /dev/null +++ b/src/app/register/other-details/get-first-non-valid-input-id.ts @@ -0,0 +1,27 @@ +import { OtherDetailsForm } from './other-details-form'; +import { ValidityUtils } from 'fully-formed'; + +export function getFirstNonValidInputId( + otherDetailsForm: InstanceType, +): string | null { + if (!ValidityUtils.isValid(otherDetailsForm.fields.party)) { + return otherDetailsForm.fields.party.id; + } + + if ( + !otherDetailsForm.fields.otherParty.state.exclude && + !ValidityUtils.isValid(otherDetailsForm.fields.otherParty) + ) { + return otherDetailsForm.fields.otherParty.id; + } + + if (!ValidityUtils.isValid(otherDetailsForm.fields.race)) { + return otherDetailsForm.fields.race.id; + } + + if (!ValidityUtils.isValid(otherDetailsForm.fields.id)) { + return otherDetailsForm.fields.id.id; + } + + return null; +} diff --git a/src/app/register/other-details/other-details-form.ts b/src/app/register/other-details/other-details-form.ts new file mode 100644 index 00000000..2555ba7e --- /dev/null +++ b/src/app/register/other-details/other-details-form.ts @@ -0,0 +1,125 @@ +import { + SubFormTemplate, + FormFactory, + Field, + PersistentField, + PersistentControlledExcludableField, + StringValidators, + Group, + Adapter, + type TransientField, + type NonTransientField, + type IField, + type ExcludableField, + type IAdapter, + type IGroup, + type Excludable, +} from 'fully-formed'; + +export const OtherDetailsForm = FormFactory.createSubForm( + class OtherDetailsTemplate extends SubFormTemplate { + public readonly name = 'otherDetails'; + public readonly autoTrim = true; + public readonly fields: [ + TransientField<'party', string>, + TransientField<'otherParty', string> & Excludable, + NonTransientField<'changedParties', boolean>, + NonTransientField<'race', string>, + NonTransientField<'id', string>, + ]; + + public readonly groups: [ + IGroup< + 'partyGroup', + [IField<'party', string>, ExcludableField<'otherParty', string>] + >, + ]; + + public readonly adapters: [IAdapter<'party', string>]; + + private readonly key = 'otherDetails'; + + public constructor() { + super(); + + const party = new PersistentField({ + name: 'party', + key: this.key + '.party', + defaultValue: '', + transient: true, + validators: [ + StringValidators.required({ + invalidMessage: + 'Please select a political party. If you do not see your party listed, select "Other."', + }), + ], + }); + + this.fields = [ + party, + new PersistentControlledExcludableField({ + name: 'otherParty', + key: this.key + '.otherParty', + controller: party, + initFn: ({ value }) => { + return { + value: '', + exclude: !/^other$/i.test(value), + }; + }, + controlFn: ({ value }) => { + return { + exclude: !/^other$/i.test(value), + }; + }, + validators: [ + StringValidators.required({ + invalidMessage: 'Please enter your political party.', + }), + ], + }), + new PersistentField({ + name: 'changedParties', + key: this.key + '.changedParties', + defaultValue: false, + }), + new PersistentField({ + name: 'race', + key: this.key + '.race', + defaultValue: '', + validators: [ + StringValidators.required({ + invalidMessage: 'Please select an option.', + }), + ], + }), + new Field({ + name: 'id', + defaultValue: '', + validators: [StringValidators.required()], + }), + ]; + + this.groups = [ + new Group({ + name: 'partyGroup', + members: [this.fields[0], this.fields[1]], + }), + ]; + + this.adapters = [ + new Adapter({ + name: 'party', + source: this.groups[0], + adaptFn: ({ value }) => { + if (value.otherParty) { + return value.otherParty; + } + + return value.party; + }, + }), + ]; + } + }, +); diff --git a/src/app/register/other-details/other-details.tsx b/src/app/register/other-details/other-details.tsx new file mode 100644 index 00000000..0def96af --- /dev/null +++ b/src/app/register/other-details/other-details.tsx @@ -0,0 +1,149 @@ +'use client'; +import { useState, useId } from 'react'; +import { useValue, ValidityUtils } from 'fully-formed'; +import { useContextSafely } from '@/hooks/use-context-safely'; +import { VoterRegistrationContext } from '../voter-registration-context'; +import { UserContext } from '@/contexts/user-context'; +import { AlertsContext } from '@/contexts/alerts-context'; +import { Select } from '@/components/form-components/select'; +import { Checkbox } from '@/components/form-components/checkbox'; +import { ExcludableContent } from '@/components/form-components/excludable-content/excludable-content'; +import { InputGroup } from '@/components/form-components/input-group'; +import { Button } from '@/components/utils/button'; +import { Input } from '@/components/form-components/input'; +import { Label } from '@/components/form-components/label'; +import { LoadingWheel } from '@/components/utils/loading-wheel'; +import { getFirstNonValidInputId } from './get-first-non-valid-input-id'; +import { focusOnElementById } from '@/utils/client/focus-on-element-by-id'; +import type { FormEventHandler } from 'react'; +import styles from './styles.module.scss'; + +export interface OtherDetailsProps { + ballotQualifiedPoliticalParties: string[]; +} + +export function OtherDetails({ + ballotQualifiedPoliticalParties, +}: OtherDetailsProps) { + const { voterRegistrationForm } = useContextSafely( + VoterRegistrationContext, + 'OtherDetails', + ); + const form = voterRegistrationForm.fields.otherDetails; + const idFieldDescriptionId = useId(); + const { registerToVote } = useContextSafely(UserContext, 'OtherDetails'); + const { showAlert } = useContextSafely(AlertsContext, 'OtherDetails'); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit: FormEventHandler = async e => { + e.preventDefault(); + if (isSubmitting) return; + + form.setSubmitted(); + + if (!ValidityUtils.isValid(form)) { + const firstNonValidInputId = getFirstNonValidInputId(form); + firstNonValidInputId && focusOnElementById(firstNonValidInputId); + return; + } + + try { + setIsSubmitting(true); + await registerToVote(voterRegistrationForm.state.value); + } catch (e) { + setIsSubmitting(false); + showAlert('Something went wrong. Please try again.', 'error'); + } + }; + + return ( +
+

Other Details

+ { + return { + text: value, + value, + }; + })} + className={styles.race} + moreInfo={{ + buttonAltText: + 'Click for more details about why we collect this information', + dialogAriaLabel: 'More details about why we collect this information', + infoComponent: ( +

+ We appreciate this information in order to measure the + effectiveness of our voter registration efforts. Additionally, + this information is required by some states. +

+ ), + }} + aria-required + /> + + +

+ Provide your driver's license, state identification card number, or + the last 4 digits of your social security number. +

+ + {isSubmitting && } + + ); +} diff --git a/src/app/register/other-details/page.tsx b/src/app/register/other-details/page.tsx new file mode 100644 index 00000000..356bd906 --- /dev/null +++ b/src/app/register/other-details/page.tsx @@ -0,0 +1,38 @@ +import dynamic from 'next/dynamic'; +import { serverContainer } from '@/services/server/container'; +import { SERVER_SERVICE_KEYS } from '@/services/server/keys'; + +interface OtherDetailsProps { + searchParams: { + state?: string; + zip?: string; + }; +} + +/* + Render the other details form on the client side to prevent hydration errors + due to reading persistent form data from sessionStorage. +*/ +const OtherDetails = dynamic( + () => import('./other-details').then(module => module.OtherDetails), + { + ssr: false, + }, +); + +export default async function Page({ searchParams }: OtherDetailsProps) { + const USStateInformation = serverContainer.get( + SERVER_SERVICE_KEYS.USStateInformation, + ); + const ballotQualifiedPoliticalParties = + await USStateInformation.getBallotQualifiedPoliticalPartiesByLocation( + searchParams.state ?? '', + searchParams.zip ?? '', + ); + + return ( + + ); +} diff --git a/src/app/register/other-details/styles.module.scss b/src/app/register/other-details/styles.module.scss new file mode 100644 index 00000000..da71165f --- /dev/null +++ b/src/app/register/other-details/styles.module.scss @@ -0,0 +1,23 @@ +@use '../../../styles/partials' as *; + +.other_party { + margin-top: 10px; +} + +.checkbox { + margin-top: 16px; + margin-bottom: $spacing-md; +} + +.select { + width: 100% !important; +} + +.race { + @extend .select; + margin-bottom: 10px; +} + +.id_explainer { + margin-bottom: $spacing-md; +} diff --git a/src/app/register/progress-bar/get-progress-percent.ts b/src/app/register/progress-bar/get-progress-percent.ts new file mode 100644 index 00000000..6a902912 --- /dev/null +++ b/src/app/register/progress-bar/get-progress-percent.ts @@ -0,0 +1,15 @@ +import { VoterRegistrationPathnames } from '../constants/voter-registration-pathnames'; +import type { ProgressPercent } from './progress-percent'; + +export function getProgressPercent(pathname: string): ProgressPercent { + switch (pathname) { + case VoterRegistrationPathnames.ELIGIBILITY: + return 25; + case VoterRegistrationPathnames.NAMES: + return 50; + case VoterRegistrationPathnames.ADDRESSES: + return 75; + default: + return 100; + } +} diff --git a/src/app/register/progress-bar/index.tsx b/src/app/register/progress-bar/index.tsx new file mode 100644 index 00000000..c34e0df9 --- /dev/null +++ b/src/app/register/progress-bar/index.tsx @@ -0,0 +1 @@ +export { ProgressBar } from './progress-bar'; diff --git a/src/app/register/progress-bar/progress-bar-context.tsx b/src/app/register/progress-bar/progress-bar-context.tsx new file mode 100644 index 00000000..b524dd46 --- /dev/null +++ b/src/app/register/progress-bar/progress-bar-context.tsx @@ -0,0 +1,42 @@ +import { useRef, useEffect, useState, ReactNode } from 'react'; +import { createNamedContext } from '@/hooks/create-named-context'; +import { ProgressPercent } from './progress-percent'; + +interface ProgressBarContextType { + progressPercent: ProgressPercent; + previousProgressPercent: ProgressPercent; +} + +type ProgressBarContextProps = { + progressPercent: ProgressPercent; + children: ReactNode; +}; + +export const ProgressBarContext = + createNamedContext('ProgressBarContext'); + +export function ProgressBarContextProvider({ + progressPercent, + children, +}: ProgressBarContextProps) { + const [state, setState] = useState({ + progressPercent, + previousProgressPercent: progressPercent, + }); + + const progressPercentRef = useRef(progressPercent); + + useEffect(() => { + setState({ + progressPercent: progressPercent, + previousProgressPercent: progressPercentRef.current, + }); + progressPercentRef.current = progressPercent; + }, [progressPercent]); + + return ( + + {children} + + ); +} diff --git a/src/app/register/progress-bar/progress-bar.tsx b/src/app/register/progress-bar/progress-bar.tsx new file mode 100644 index 00000000..caf52d1c --- /dev/null +++ b/src/app/register/progress-bar/progress-bar.tsx @@ -0,0 +1,27 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { usePathname } from 'next/navigation'; +import { ProgressIndicatorSVG } from './svg/progress-indicator-svg'; +import { ProgressBarContextProvider } from './progress-bar-context'; +import { getProgressPercent } from './get-progress-percent'; + +/** + * Displays an animated progress bar. The progress percentage displayed by the + * component is determined by the current pathname. + */ +export function ProgressBar() { + const pathname = usePathname(); + const [progressPercent, setProgressPercent] = useState( + getProgressPercent(pathname), + ); + + useEffect(() => { + setProgressPercent(getProgressPercent(pathname)); + }, [pathname]); + + return ( + + + + ); +} diff --git a/src/app/register/progress-bar/progress-percent.ts b/src/app/register/progress-bar/progress-percent.ts new file mode 100644 index 00000000..4d14a9f7 --- /dev/null +++ b/src/app/register/progress-bar/progress-percent.ts @@ -0,0 +1 @@ +export type ProgressPercent = 25 | 50 | 75 | 100; diff --git a/src/app/register/progress-bar/svg/animated-color-stop.tsx b/src/app/register/progress-bar/svg/animated-color-stop.tsx new file mode 100644 index 00000000..d564a43b --- /dev/null +++ b/src/app/register/progress-bar/svg/animated-color-stop.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; +import { ProgressBarContext } from '../progress-bar-context'; +import { calculateColorStopOffset } from './util/calculate-color-stop-offset'; +import { useContextSafely } from '@/hooks/use-context-safely'; + +interface AnimatedColorStopProps { + color: string; +} + +interface AnimatedColorStopState { + offset: number; + animationValues: string; + willAnimate: boolean; +} + +export function AnimatedColorStop({ color }: AnimatedColorStopProps) { + const progressBarCtx = useContextSafely( + ProgressBarContext, + 'AnimatedColorStop', + ); + const progressBarState = progressBarCtx; + + const animateTagRef = useRef(null); + + const [state, setState] = useState({ + offset: calculateColorStopOffset(progressBarState.progressPercent), + animationValues: `${calculateColorStopOffset(progressBarState.progressPercent)};${calculateColorStopOffset(progressBarState.progressPercent)}`, + willAnimate: false, + }); + + useEffect(() => { + const willAnimate = + progressBarState.progressPercent > + progressBarState.previousProgressPercent; + const offset = + willAnimate ? + calculateColorStopOffset(progressBarState.previousProgressPercent) + : calculateColorStopOffset(progressBarState.progressPercent); + const animationValues = `${offset};${calculateColorStopOffset(progressBarState.progressPercent)}`; + + setState({ + offset, + animationValues, + willAnimate, + }); + }, [progressBarState]); + + useEffect(() => { + if (state.willAnimate && animateTagRef.current) { + (animateTagRef.current as any).beginElement(); + } + }, [state]); + + return state.willAnimate ? + + + + : ; +} diff --git a/src/app/register/progress-bar/svg/constants.ts b/src/app/register/progress-bar/svg/constants.ts new file mode 100644 index 00000000..95c6411a --- /dev/null +++ b/src/app/register/progress-bar/svg/constants.ts @@ -0,0 +1,21 @@ +export enum Dimensions { + CIRCLE_RADIUS = 7.5, + X_OFFSET = CIRCLE_RADIUS, + BAR_HEIGHT = 4, + BAR_WIDTH = 180, + VERTICAL_PADDING = 2, + VIEWBOX_HEIGHT = CIRCLE_RADIUS * 2 + VERTICAL_PADDING * 2, + VIEWBOX_WIDTH = BAR_WIDTH + CIRCLE_RADIUS * 2, + VERTICAL_CENTER = VIEWBOX_HEIGHT / 2, +} + +export enum LinearGradientIds { + TEAL_YELLOW_GRADIENT_ID = 'teal_yellow_gradient', + TRANSPARENT_GRAY_GRADIENT_ID = 'transparent_gray_gradient', +} + +export enum Colors { + LIGHT_GRAY = '#dedede', + TEAL = '#02ddc3', + YELLOW = '#ffed10', +} diff --git a/src/app/register/progress-bar/svg/linear-gradients.tsx b/src/app/register/progress-bar/svg/linear-gradients.tsx new file mode 100644 index 00000000..327440fd --- /dev/null +++ b/src/app/register/progress-bar/svg/linear-gradients.tsx @@ -0,0 +1,11 @@ +import { TealYellowGradient } from './teal-yellow-gradient'; +import { TransparentGrayGradient } from './transparent-gray-gradient'; + +export function LinearGradients() { + return ( + + + + + ); +} diff --git a/src/app/register/progress-bar/svg/progress-indicator-group.tsx b/src/app/register/progress-bar/svg/progress-indicator-group.tsx new file mode 100644 index 00000000..e6cb45c9 --- /dev/null +++ b/src/app/register/progress-bar/svg/progress-indicator-group.tsx @@ -0,0 +1,42 @@ +import { Dimensions } from './constants'; + +type ElementId = `#${string}`; + +interface ProgressIndicatorGroupProps { + fillId: ElementId; +} + +export function ProgressIndicatorGroup({ + fillId, +}: ProgressIndicatorGroupProps) { + return ( + + + + + + + + ); +} diff --git a/src/app/register/progress-bar/svg/progress-indicator-svg.tsx b/src/app/register/progress-bar/svg/progress-indicator-svg.tsx new file mode 100644 index 00000000..c5fb7b5e --- /dev/null +++ b/src/app/register/progress-bar/svg/progress-indicator-svg.tsx @@ -0,0 +1,20 @@ +import { Dimensions, LinearGradientIds } from './constants'; +import { LinearGradients } from './linear-gradients'; +import { ProgressIndicatorGroup } from './progress-indicator-group'; + +export function ProgressIndicatorSVG() { + return ( + + + + + + ); +} diff --git a/src/app/register/progress-bar/svg/teal-yellow-gradient.tsx b/src/app/register/progress-bar/svg/teal-yellow-gradient.tsx new file mode 100644 index 00000000..5ef8f6aa --- /dev/null +++ b/src/app/register/progress-bar/svg/teal-yellow-gradient.tsx @@ -0,0 +1,13 @@ +import { Colors, LinearGradientIds } from './constants'; + +export function TealYellowGradient() { + return ( + + + + + ); +} diff --git a/src/app/register/progress-bar/svg/transparent-gray-gradient.tsx b/src/app/register/progress-bar/svg/transparent-gray-gradient.tsx new file mode 100644 index 00000000..b5f0ee22 --- /dev/null +++ b/src/app/register/progress-bar/svg/transparent-gray-gradient.tsx @@ -0,0 +1,14 @@ +import { AnimatedColorStop } from './animated-color-stop'; +import { Colors, LinearGradientIds } from './constants'; + +export function TransparentGrayGradient() { + return ( + + + + + ); +} diff --git a/src/app/register/progress-bar/svg/util/calculate-color-stop-offset.ts b/src/app/register/progress-bar/svg/util/calculate-color-stop-offset.ts new file mode 100644 index 00000000..306e0a3e --- /dev/null +++ b/src/app/register/progress-bar/svg/util/calculate-color-stop-offset.ts @@ -0,0 +1,28 @@ +import { Dimensions } from '../constants'; +import { ProgressPercent } from '../../progress-percent'; + +export function calculateColorStopOffset(progressPercent: ProgressPercent) { + switch (progressPercent) { + case 25: + return ( + (Dimensions.X_OFFSET + Dimensions.CIRCLE_RADIUS) / + Dimensions.VIEWBOX_WIDTH + ); + case 50: + return ( + (Dimensions.X_OFFSET + + Dimensions.BAR_WIDTH / 3 + + Dimensions.CIRCLE_RADIUS) / + Dimensions.VIEWBOX_WIDTH + ); + case 75: + return ( + (Dimensions.X_OFFSET + + (Dimensions.BAR_WIDTH * 2) / 3 + + Dimensions.CIRCLE_RADIUS) / + Dimensions.VIEWBOX_WIDTH + ); + case 100: + return 1; + } +} diff --git a/src/app/register/styles.module.scss b/src/app/register/styles.module.scss new file mode 100644 index 00000000..417aaf3f --- /dev/null +++ b/src/app/register/styles.module.scss @@ -0,0 +1,14 @@ +@use '../../styles/partials' as *; + +.container { + padding-left: $spacing-vertical-gutter; + padding-right: $spacing-vertical-gutter; + padding-top: $spacing-lg; +} + +.progress_bar_container { + margin-top: $spacing-sm; + margin-left: 50px; + margin-right: 50px; + margin-bottom: $spacing-lg; +} diff --git a/src/app/register/utils/phone-validator.ts b/src/app/register/utils/phone-validator.ts new file mode 100644 index 00000000..d0424e2e --- /dev/null +++ b/src/app/register/utils/phone-validator.ts @@ -0,0 +1,28 @@ +import { IValidator, ValidatorResult, Validity } from 'fully-formed'; + +export class PhoneValidator implements IValidator { + private phonePattern = /^\d{10}$/; + + validate(value: string): ValidatorResult { + const trimmed = value.trim(); + if (!trimmed.length) { + return { + validity: Validity.Valid, + }; + } + + const isValid = this.phonePattern.test(trimmed); + + return isValid ? + { + validity: Validity.Valid, + } + : { + validity: Validity.Invalid, + message: { + text: 'Please enter a valid 10-digit phone number, or leave this field blank.', + validity: Validity.Invalid, + }, + }; + } +} diff --git a/src/app/register/utils/use-redirect-to-first-incomplete-page.ts b/src/app/register/utils/use-redirect-to-first-incomplete-page.ts new file mode 100644 index 00000000..2a3e9c91 --- /dev/null +++ b/src/app/register/utils/use-redirect-to-first-incomplete-page.ts @@ -0,0 +1,43 @@ +import { useLayoutEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { usePipe, ValidityUtils } from 'fully-formed'; +import { VoterRegistrationPathnames } from '../constants/voter-registration-pathnames'; +import type { VoterRegistrationForm } from '../voter-registration-form'; + +export function useRedirectToFirstIncompletePage( + voterRegistrationForm: InstanceType, +) { + const router = useRouter(); + const pathname = usePathname(); + const firstIncompletePage = usePipe(voterRegistrationForm, () => { + return getFirstIncompletePage(voterRegistrationForm); + }); + + useLayoutEffect(() => { + const currentPageIndex = VoterRegistrationPathnames.getPathIndex(pathname); + const firstIncompletePageIndex = + VoterRegistrationPathnames.getPathIndex(firstIncompletePage); + + if (currentPageIndex > firstIncompletePageIndex) { + router.push(firstIncompletePage); + } + }, [firstIncompletePage, pathname, router]); +} + +function getFirstIncompletePage({ + fields, +}: InstanceType) { + if (!ValidityUtils.isValidOrCaution(fields.eligibility)) { + return VoterRegistrationPathnames.ELIGIBILITY; + } + + if (!ValidityUtils.isValidOrCaution(fields.names)) { + return VoterRegistrationPathnames.NAMES; + } + + if (!ValidityUtils.isValidOrCaution(fields.addresses)) { + return VoterRegistrationPathnames.ADDRESSES; + } + + return VoterRegistrationPathnames.OTHER_DETAILS; +} diff --git a/src/app/register/utils/zip-code-validator.ts b/src/app/register/utils/zip-code-validator.ts new file mode 100644 index 00000000..d8c6d898 --- /dev/null +++ b/src/app/register/utils/zip-code-validator.ts @@ -0,0 +1,44 @@ +import { IValidator, ValidatorResult, Validity } from 'fully-formed'; + +interface ZipCodeValidatorOpts { + trimBeforeValidation?: boolean; +} + +export class ZipCodeValidator implements IValidator { + private trimBeforeValidation: boolean; + private pattern = /^\d{5}$/; + + public constructor(opts?: ZipCodeValidatorOpts) { + this.trimBeforeValidation = !!opts?.trimBeforeValidation; + } + + validate(value: string): ValidatorResult { + if (this.trimBeforeValidation) value = value.trim(); + + if (!value.length) { + return { + validity: Validity.Invalid, + message: { + text: 'Please enter your ZIP code.', + validity: Validity.Invalid, + }, + }; + } + + const isValid = this.pattern.test(value); + + if (!isValid) { + return { + validity: Validity.Invalid, + message: { + text: 'Please enter a 5-digit ZIP code.', + validity: Validity.Invalid, + }, + }; + } + + return { + validity: Validity.Valid, + }; + } +} diff --git a/src/app/register/voter-registration-context.ts b/src/app/register/voter-registration-context.ts new file mode 100644 index 00000000..934424fc --- /dev/null +++ b/src/app/register/voter-registration-context.ts @@ -0,0 +1,10 @@ +'use client'; +import { createNamedContext } from '@/hooks/create-named-context'; +import { VoterRegistrationForm } from './voter-registration-form'; + +type VoterRegistrationContextType = { + voterRegistrationForm: InstanceType; +}; + +export const VoterRegistrationContext = + createNamedContext('VoterRegistrationContext'); diff --git a/src/app/register/voter-registration-form.ts b/src/app/register/voter-registration-form.ts new file mode 100644 index 00000000..140428a9 --- /dev/null +++ b/src/app/register/voter-registration-form.ts @@ -0,0 +1,30 @@ +import { FormTemplate, FormFactory } from 'fully-formed'; +import { EligibilityForm } from './eligibility/eligibility-form'; +import { NamesForm } from './names/names-form'; +import { AddressesForm } from './addresses/addresses-form'; +import { OtherDetailsForm } from './other-details/other-details-form'; +import type { User } from '@/model/types/user'; + +export const VoterRegistrationForm = FormFactory.createForm( + class VoterRegistrationTemplate extends FormTemplate { + public readonly fields: [ + InstanceType, + InstanceType, + InstanceType, + InstanceType, + ]; + + public constructor(user: User | null) { + super(); + const eligibilityForm = new EligibilityForm(user?.email ?? ''); + const addressesForm = new AddressesForm(eligibilityForm.fields.zip); + + this.fields = [ + eligibilityForm, + new NamesForm(), + addressesForm, + new OtherDetailsForm(), + ]; + } + }, +); diff --git a/src/components/form-components/checkbox/checkbox.tsx b/src/components/form-components/checkbox/checkbox.tsx new file mode 100644 index 00000000..f26173a1 --- /dev/null +++ b/src/components/form-components/checkbox/checkbox.tsx @@ -0,0 +1,62 @@ +'use client'; +import { + useId, + type CSSProperties, + type ChangeEventHandler, + type ReactNode, +} from 'react'; +import styles from './styles.module.scss'; + +type CheckboxProps = { + name: string; + labelContent: ReactNode; + checked: boolean; + onChange: ChangeEventHandler; + id?: string; + containerClassName?: string; + containerStyle?: CSSProperties; + ['aria-required']?: boolean; + ['aria-describedby']?: string; + ['aria-invalid']?: boolean; +}; + +export function Checkbox({ + name, + labelContent, + checked, + onChange, + id, + containerClassName, + containerStyle, + ['aria-required']: ariaRequired, + ['aria-describedby']: ariaDescribedBy, + ['aria-invalid']: ariaInvalid, +}: CheckboxProps) { + const defaultId = useId(); + + return ( +
+ + +
+ ); +} diff --git a/src/components/form-components/checkbox/index.tsx b/src/components/form-components/checkbox/index.tsx new file mode 100644 index 00000000..83a0ba15 --- /dev/null +++ b/src/components/form-components/checkbox/index.tsx @@ -0,0 +1 @@ +export { Checkbox } from './checkbox'; diff --git a/src/components/form-components/checkbox/styles.module.scss b/src/components/form-components/checkbox/styles.module.scss new file mode 100644 index 00000000..42334418 --- /dev/null +++ b/src/components/form-components/checkbox/styles.module.scss @@ -0,0 +1,51 @@ +@use '../../../styles/partials' as *; + +.container { + display: flex; +} + +.checkbox { + min-width: 16px; + max-width: 16px; + min-height: 16px; + max-height: 16px; + margin-right: 12px; +} + +.checkbox:focus { + outline: 1px solid $color-black-8by8; + outline-offset: 6px; +} + +.label { + @extend .b1; +} + +@supports (appearance: none) or (-webkit-appearance: none) or + (-moz-appearance: none) { + .checkbox { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: $color-white; + border: 2px solid $color-black-8by8; + position: relative; + } + + .checkbox::after { + content: ''; + background-image: url('../../../../public/static/images/components/checkbox/checkmark.svg'); + background-repeat: no-repeat; + background-position: center; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + visibility: hidden; + } + + .checkbox:checked::after { + visibility: visible; + } +} diff --git a/src/components/form-components/excludable-content/excludable-content.tsx b/src/components/form-components/excludable-content/excludable-content.tsx new file mode 100644 index 00000000..65d6a5be --- /dev/null +++ b/src/components/form-components/excludable-content/excludable-content.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useExclude, type Excludable } from 'fully-formed'; +import type { ReactNode } from 'react'; + +interface ExcludableContentProps { + excludableField: Excludable; + children?: ReactNode; +} + +/** + * Accepts an {@link Excludable} field and child components. When the field is + * excluded, the child components will not be rendered. + * + * @param props - {@link ExcludableContentProps} + * @returns child components if the field is included, `null` if the field is + * excluded. + */ +export function ExcludableContent({ + excludableField, + children, +}: ExcludableContentProps) { + const exclude = useExclude(excludableField); + + return <>{exclude ? null : children}; +} diff --git a/src/components/form-components/excludable-content/index.tsx b/src/components/form-components/excludable-content/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/form-components/input-group/input-group.tsx b/src/components/form-components/input-group/input-group.tsx index 5ed2cd23..d00d834c 100644 --- a/src/components/form-components/input-group/input-group.tsx +++ b/src/components/form-components/input-group/input-group.tsx @@ -1,8 +1,18 @@ +'use client'; +import { ValidityUtils } from 'fully-formed'; +import Image from 'next/image'; import { Label } from '../label'; import { Input } from '../input'; import { Messages } from '../messages'; -import { usePipe, type FieldOfType, type IGroup } from 'fully-formed'; +import { + useMultiPipe, + usePipe, + type FieldOfType, + type IGroup, +} from 'fully-formed'; import type { CSSProperties, ReactNode } from 'react'; +import warningIconLight from '@/../public/static/images/components/shared/warning-icon-light.svg'; +import styles from './styles.module.scss'; type InputGroupProps = { field: FieldOfType; @@ -16,6 +26,7 @@ type InputGroupProps = { disabled?: boolean; autoComplete?: string; maxLength?: number; + max?: string; ['aria-required']?: boolean; }; @@ -49,6 +60,7 @@ export function InputGroup({ disabled, autoComplete, maxLength, + max, ['aria-required']: ariaRequired, }: InputGroupProps) { const messagesId = `${field.id}-messages`; @@ -56,6 +68,18 @@ export function InputGroup({ return !(state.hasBeenModified || state.hasBeenBlurred || state.submitted); }); + const showWarningIcon = useMultiPipe([field, ...groups], states => { + const validity = ValidityUtils.minValidity(states); + const fieldState = states[0]; + + return ( + ValidityUtils.isCaution(validity) && + (fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted) + ); + }); + return (
); } diff --git a/src/components/form-components/input-group/styles.module.scss b/src/components/form-components/input-group/styles.module.scss new file mode 100644 index 00000000..c970e514 --- /dev/null +++ b/src/components/form-components/input-group/styles.module.scss @@ -0,0 +1,11 @@ +@use '../../../styles/partials' as *; + +.messages_container { + height: $font-size-xxs + 5px; +} + +.warning_icon { + width: 16.55px; + height: 14px; + margin-top: 4px; +} diff --git a/src/components/form-components/input/input.tsx b/src/components/form-components/input/input.tsx index ed48b863..12a52efe 100644 --- a/src/components/form-components/input/input.tsx +++ b/src/components/form-components/input/input.tsx @@ -1,3 +1,5 @@ +'use client'; +import { useId, type CSSProperties } from 'react'; import { useUserInput, useFocusEvents, @@ -8,16 +10,15 @@ import { type Field, type Group, } from 'fully-formed'; -import type { CSSProperties } from 'react'; import styles from './styles.module.scss'; interface InputProps { /** - * A Fully Formed {@link Field} that will control the state of the input. + * A {@link Field} that will control the state of the input. */ field: FieldOfType; /** - * An array of Fully Formed {@link Group}s. If these groups' validators have + * An array of {@link Group}s. If these groups' validators have * executed and returned an invalid result, the input will appear invalid. */ groups?: IGroup[]; @@ -33,6 +34,7 @@ interface InputProps { disabled?: boolean; autoComplete?: string; maxLength?: number; + max?: string; className?: string; style?: CSSProperties; ['aria-required']?: boolean; @@ -61,6 +63,7 @@ export function Input({ disabled, autoComplete, maxLength, + max, className: classNameProp, style, ['aria-required']: ariaRequired, @@ -84,12 +87,15 @@ export function Input({ }); if ( - ValidityUtils.isInvalid(validity) && - (fieldState.hasBeenModified || - fieldState.hasBeenBlurred || - fieldState.submitted) + fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted ) { - classNames.push(styles.invalid); + if (ValidityUtils.isCaution(validity)) { + classNames.push(styles.caution); + } else if (ValidityUtils.isInvalid(validity)) { + classNames.push(styles.invalid); + } } if (classNameProp) { @@ -111,22 +117,51 @@ export function Input({ ); }); + const warningMessage = useMultiPipe([field, ...groups], states => { + const validity = ValidityUtils.minValidity(states); + const fieldState = states[0]; + + return ( + ValidityUtils.isCaution(validity) && + (fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted) + ) ? + 'The value of this field could not be confirmed. Please verify that it is correct.' + : undefined; + }); + + const warningMessageId = useId(); + return ( - + <> + + + {warningMessage} + + ); } diff --git a/src/components/form-components/input/styles.module.scss b/src/components/form-components/input/styles.module.scss index 598c8d52..08d47d8b 100644 --- a/src/components/form-components/input/styles.module.scss +++ b/src/components/form-components/input/styles.module.scss @@ -14,6 +14,7 @@ padding: 8px 0 8px 0; width: 100%; transition: color 250ms linear; + position: relative; &:focus { outline: none; @@ -37,6 +38,7 @@ } .input[type='number'] { + appearance: textField; -moz-appearance: textfield; } @@ -51,3 +53,7 @@ .invalid { border-color: $color-error; } + +.caution { + border-color: $color-review-light; +} diff --git a/src/components/form-components/label/label.tsx b/src/components/form-components/label/label.tsx index 45db39d8..d9503317 100644 --- a/src/components/form-components/label/label.tsx +++ b/src/components/form-components/label/label.tsx @@ -37,7 +37,7 @@ export function Label({ field, variant, children }: LabelProps) { if ( variant === 'floating' && - !(state.isInFocus || state.hasBeenBlurred || state.submitted) + !(state.value || state.isInFocus || state.hasBeenBlurred) ) { classNames.push(styles.pristine_floating_label); } diff --git a/src/components/form-components/messages/messages.tsx b/src/components/form-components/messages/messages.tsx index c2be523f..48bfda58 100644 --- a/src/components/form-components/messages/messages.tsx +++ b/src/components/form-components/messages/messages.tsx @@ -1,3 +1,4 @@ +'use client'; import { useMessages, type MessageBearer, diff --git a/src/components/form-components/messages/styles.module.scss b/src/components/form-components/messages/styles.module.scss index 39609e80..5742da99 100644 --- a/src/components/form-components/messages/styles.module.scss +++ b/src/components/form-components/messages/styles.module.scss @@ -13,7 +13,3 @@ visibility: hidden; height: 0; } - -.container { - min-height: calc($font-size-xxs + 5px); -} diff --git a/src/components/form-components/phone-input-group/index.tsx b/src/components/form-components/phone-input-group/index.tsx new file mode 100644 index 00000000..7c0763ab --- /dev/null +++ b/src/components/form-components/phone-input-group/index.tsx @@ -0,0 +1 @@ +export { PhoneInputGroup } from './phone-input-group'; diff --git a/src/components/form-components/phone-input-group/phone-input-group.tsx b/src/components/form-components/phone-input-group/phone-input-group.tsx new file mode 100644 index 00000000..0a29cf3e --- /dev/null +++ b/src/components/form-components/phone-input-group/phone-input-group.tsx @@ -0,0 +1,90 @@ +'use client'; +import { + usePipe, + useMultiPipe, + ValidityUtils, + type FieldOfType, + type IGroup, +} from 'fully-formed'; +import Image from 'next/image'; +import { Label } from '../label'; +import { PhoneInput } from '../phone-input/phone-input'; +import { Messages } from '../messages'; +import type { CSSProperties, ReactNode } from 'react'; +import warningIconLight from '@/../public/static/images/components/shared/warning-icon-light.svg'; +import styles from './styles.module.scss'; + +type InputGroupProps = { + field: FieldOfType; + groups?: IGroup[]; + labelVariant: 'floating' | 'stationary'; + labelContent: ReactNode; + containerClassName?: string; + containerStyle?: CSSProperties; + placeholder?: string; + disabled?: boolean; + autoComplete?: string; + ['aria-required']?: boolean; +}; + +export function PhoneInputGroup({ + field, + groups = [], + labelVariant, + labelContent, + containerClassName, + containerStyle, + placeholder, + disabled, + autoComplete, + ['aria-required']: ariaRequired, +}: InputGroupProps) { + const messagesId = `${field.id}-messages`; + const hideMessages = usePipe(field, state => { + return !(state.hasBeenModified || state.hasBeenBlurred || state.submitted); + }); + + const showWarningIcon = useMultiPipe([field, ...groups], states => { + const validity = ValidityUtils.minValidity(states); + const fieldState = states[0]; + + return ( + ValidityUtils.isCaution(validity) && + (fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted) + ); + }); + + return ( +
+ + +
+ {showWarningIcon && ( + Warning Icon + )} + +
+
+ ); +} diff --git a/src/components/form-components/phone-input-group/styles.module.scss b/src/components/form-components/phone-input-group/styles.module.scss new file mode 100644 index 00000000..c970e514 --- /dev/null +++ b/src/components/form-components/phone-input-group/styles.module.scss @@ -0,0 +1,11 @@ +@use '../../../styles/partials' as *; + +.messages_container { + height: $font-size-xxs + 5px; +} + +.warning_icon { + width: 16.55px; + height: 14px; + margin-top: 4px; +} diff --git a/src/components/form-components/phone-input/index.tsx b/src/components/form-components/phone-input/index.tsx new file mode 100644 index 00000000..c95e71aa --- /dev/null +++ b/src/components/form-components/phone-input/index.tsx @@ -0,0 +1 @@ +export { PhoneInput } from './phone-input'; diff --git a/src/components/form-components/phone-input/phone-input-internals.ts b/src/components/form-components/phone-input/phone-input-internals.ts new file mode 100644 index 00000000..0708ab03 --- /dev/null +++ b/src/components/form-components/phone-input/phone-input-internals.ts @@ -0,0 +1,318 @@ +import { replaceStringSegment } from '@/utils/shared/replace-string-segment'; +import type { FieldOfType } from 'fully-formed'; +import type { MutableRefObject, KeyboardEvent, ChangeEvent } from 'react'; + +export class PhoneInputInternals { + private static readonly PHONE_NUMBER_LENGTH = 10; + private static readonly ArrowKeys = { + LEFT: 'ArrowLeft', + RIGHT: 'ArrowRight', + }; + + public static formatPhoneNumber(unformattedPhoneNumber: string) { + if (unformattedPhoneNumber.length >= 7) { + return `(${unformattedPhoneNumber.slice(0, 3)}) ${unformattedPhoneNumber.slice(3, 6)}-${unformattedPhoneNumber.slice(6)}`; + } + + if (unformattedPhoneNumber.length >= 4) { + return `(${unformattedPhoneNumber.slice(0, 3)}) ${unformattedPhoneNumber.slice(3)}`; + } + + return unformattedPhoneNumber; + } + + public static handleKeyDown(event: KeyboardEvent) { + const { key } = event; + if (key === this.ArrowKeys.LEFT || key === this.ArrowKeys.RIGHT) { + event.preventDefault(); + key === this.ArrowKeys.LEFT ? + this.handleLeftArrow(event) + : this.handleRightArrow(event); + } + } + + public static handleAutoComplete( + event: ChangeEvent, + field: FieldOfType, + ) { + field.setValue(event.target.value); + } + + public static handleBeforeInput( + event: InputEvent, + field: FieldOfType, + cursorPositionRef: MutableRefObject, + ) { + if ( + event.data || + event.inputType === 'deleteContentBackward' || + event.inputType === 'deleteContentForward' + ) { + event.preventDefault(); + } + + if (event.data) { + this.handleDataInput(event, field, cursorPositionRef); + } else if (event.inputType === 'deleteContentBackward') { + this.handleBackwardsDelete(event, field, cursorPositionRef); + } else if (event.inputType === 'deleteContentForward') { + this.handleForwardsDelete(event, field, cursorPositionRef); + } + } + + private static handleLeftArrow(event: KeyboardEvent) { + this.moveCursor(event); + } + + private static handleRightArrow(event: KeyboardEvent) { + const target = event.target as HTMLInputElement; + if (target.selectionStart === target.value.length) return; + this.moveCursor(event); + } + + private static moveCursor(event: KeyboardEvent) { + const target = event.target as HTMLInputElement; + const { key } = event; + + let unformattedSelectionStart = + this.formattedSelectionPositionToUnformatted( + target.selectionStart!, + target.value.length, + ); + + unformattedSelectionStart = + key === this.ArrowKeys.LEFT ? + unformattedSelectionStart - 1 + : unformattedSelectionStart + 1; + + const newSelectionStart = this.unformattedSelectionPositionToFormatted( + unformattedSelectionStart, + target.value.split('').filter(c => { + return this.isDigit(c); + }).length, + ); + + target.setSelectionRange(newSelectionStart, newSelectionStart); + } + + private static handleDataInput( + event: InputEvent, + field: FieldOfType, + cursorPositionRef: MutableRefObject, + ) { + const target = event.target as HTMLInputElement; + const selectionStart = target.selectionStart!; + const selectionEnd = target.selectionEnd!; + const input = event.data!; + const currentUnformattedValue = field.state.value; + + const unformattedSelectionStart = + this.formattedSelectionPositionToUnformatted( + selectionStart, + target.value.length, + ); + + const unformattedSelectionEnd = + this.formattedSelectionPositionToUnformatted( + selectionEnd, + target.value.length, + ); + + const filteredAndTruncatedInput = this.filterAndTruncateInput( + input, + currentUnformattedValue, + unformattedSelectionStart, + unformattedSelectionEnd, + ); + + const updatedValue = replaceStringSegment( + currentUnformattedValue, + filteredAndTruncatedInput, + unformattedSelectionStart, + unformattedSelectionEnd, + ); + + if (updatedValue !== currentUnformattedValue) { + cursorPositionRef.current = this.unformattedSelectionPositionToFormatted( + unformattedSelectionStart + filteredAndTruncatedInput.length, + updatedValue.length, + ); + + field.setValue(updatedValue); + } else { + target.setSelectionRange(selectionEnd, selectionEnd); + } + } + + private static handleBackwardsDelete( + event: InputEvent, + field: FieldOfType, + cursorPositionRef: MutableRefObject, + ) { + const target = event.target as HTMLInputElement; + const formattedSelectionStart = target.selectionStart!; + const formattedSelectionEnd = target.selectionEnd!; + const { value } = target; + + const formattedSelectionLength = + formattedSelectionEnd - formattedSelectionStart; + + if ( + this.isOnlyPunctuationHighlighted( + target.value, + formattedSelectionStart, + formattedSelectionEnd, + ) + ) + return; + + let unformattedSelectionStart = + this.formattedSelectionPositionToUnformatted( + formattedSelectionStart, + value.length, + ); + + if (formattedSelectionLength === 0) { + unformattedSelectionStart = Math.max(unformattedSelectionStart - 1, 0); + } + + const unformattedSelectionEnd = + this.formattedSelectionPositionToUnformatted( + formattedSelectionEnd, + value.length, + ); + + const updatedValue = replaceStringSegment( + field.state.value, + '', + unformattedSelectionStart, + unformattedSelectionEnd, + ); + + cursorPositionRef.current = this.unformattedSelectionPositionToFormatted( + unformattedSelectionStart, + updatedValue.length, + ); + + field.setValue(updatedValue); + } + + private static handleForwardsDelete( + event: InputEvent, + field: FieldOfType, + cursorPositionRef: MutableRefObject, + ) { + const target = event.target as HTMLInputElement; + const formattedSelectionStart = target.selectionStart!; + const formattedSelectionEnd = target.selectionEnd!; + const { value } = target; + + const formattedSelectionLength = + formattedSelectionEnd - formattedSelectionStart; + + if ( + this.isOnlyPunctuationHighlighted( + target.value, + formattedSelectionStart, + formattedSelectionEnd, + ) + ) + return; + + const unformattedSelectionStart = + this.formattedSelectionPositionToUnformatted( + formattedSelectionStart, + value.length, + ); + + let unformattedSelectionEnd = this.formattedSelectionPositionToUnformatted( + formattedSelectionEnd, + value.length, + ); + + if (formattedSelectionLength === 0) { + unformattedSelectionEnd = Math.min( + unformattedSelectionEnd + 1, + this.PHONE_NUMBER_LENGTH, + ); + } + + const updatedValue = replaceStringSegment( + field.state.value, + '', + unformattedSelectionStart, + unformattedSelectionEnd, + ); + + cursorPositionRef.current = this.unformattedSelectionPositionToFormatted( + unformattedSelectionStart, + updatedValue.length, + ); + + field.setValue(updatedValue); + } + + private static formattedSelectionPositionToUnformatted( + formattedSelectionPosition: number, + formattedLength: number, + ) { + if (formattedLength <= 3) return formattedSelectionPosition; + + const positionMap = [0, 0, 1, 2, 3, 3, 3, 4, 5, 6, 6, 7, 8, 9, 10]; + return positionMap[formattedSelectionPosition]; + } + + private static unformattedSelectionPositionToFormatted( + unformattedSelectionPosition: number, + unformattedLength: number, + ) { + if (unformattedLength <= 3) return unformattedSelectionPosition; + + const positionMap = [1, 2, 3, 4, 7, 8, 9, 11, 12, 13, 14]; + return positionMap[unformattedSelectionPosition]; + } + + private static filterAndTruncateInput( + input: string, + fieldValue: string, + unformattedSelectionStart: number, + unformattedSelectionEnd: number, + ) { + const unformattedSelectionLength = + unformattedSelectionEnd - unformattedSelectionStart; + + const availableDigits = + this.PHONE_NUMBER_LENGTH - + (fieldValue.length - unformattedSelectionLength); + + const filteredAndTruncatedInput = this.toDigits(input).slice( + 0, + availableDigits, + ); + + return filteredAndTruncatedInput; + } + + private static toDigits(str: string) { + return str + .split('') + .filter(c => this.isDigit(c)) + .join(''); + } + + private static isDigit(c: string) { + return /^\d$/.test(c); + } + + private static isOnlyPunctuationHighlighted( + formattedValue: string, + formattedSelectionStart: number, + formattedSelectionEnd: number, + ) { + const selection = formattedValue.slice( + formattedSelectionStart, + formattedSelectionEnd, + ); + return selection.match(/^\)\s$|^[(\s)-]$/); + } +} diff --git a/src/components/form-components/phone-input/phone-input.tsx b/src/components/form-components/phone-input/phone-input.tsx new file mode 100644 index 00000000..7b1d4cbc --- /dev/null +++ b/src/components/form-components/phone-input/phone-input.tsx @@ -0,0 +1,167 @@ +'use client'; +import { useRef, useEffect, useId } from 'react'; +import { + usePipe, + useMultiPipe, + useFocusEvents, + ValidityUtils, + type FieldOfType, + type IGroup, +} from 'fully-formed'; +import { PhoneInputInternals } from './phone-input-internals'; +import type { CSSProperties } from 'react'; +import styles from './styles.module.scss'; + +interface PhoneInputProps { + field: FieldOfType; + groups?: IGroup[]; + showText?: boolean; + placeholder?: string; + disabled?: boolean; + autoComplete?: string; + className?: string; + style?: CSSProperties; + ['aria-required']?: boolean; + ['aria-describedby']?: string; +} + +export function PhoneInput({ + field, + groups = [], + showText, + placeholder, + disabled, + autoComplete, + className: classNameProp, + style, + ['aria-required']: ariaRequired, + ['aria-describedby']: ariaDescribedBy, +}: PhoneInputProps) { + const value = usePipe(field, ({ value }) => { + return PhoneInputInternals.formatPhoneNumber(value); + }); + const inputRef = useRef(null); + const cursorPositionRef = useRef(null); + const className = useMultiPipe([field, ...groups], states => { + const classNames = [styles.input]; + const fieldState = states[0]; + + if ( + showText || + fieldState.isInFocus || + fieldState.hasBeenBlurred || + fieldState.value + ) { + classNames.push(styles.show_text); + } + + const validity = ValidityUtils.minValidity(states, { + pruneUnvalidatedGroups: true, + }); + + if ( + fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted + ) { + if (ValidityUtils.isCaution(validity)) { + classNames.push(styles.caution); + } else if (ValidityUtils.isInvalid(validity)) { + classNames.push(styles.invalid); + } + } + + if (classNameProp) { + classNames.push(classNameProp); + } + + return classNames.join(' '); + }); + + const ariaInvalid = useMultiPipe([field, ...groups], states => { + const validity = ValidityUtils.minValidity(states); + const fieldState = states[0]; + + return ( + ValidityUtils.isInvalid(validity) && + (fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted) + ); + }); + + const warningMessage = useMultiPipe([field, ...groups], states => { + const validity = ValidityUtils.minValidity(states); + const fieldState = states[0]; + + return ( + ValidityUtils.isCaution(validity) && + (fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted) + ) ? + 'The value of this field could not be confirmed. Please verify that it is correct.' + : undefined; + }); + + const warningMessageId = useId(); + + useEffect(() => { + function handleBeforeInput(event: InputEvent) { + PhoneInputInternals.handleBeforeInput(event, field, cursorPositionRef); + } + + const input = inputRef.current; + + input?.addEventListener('beforeinput', handleBeforeInput); + + return () => { + input?.removeEventListener('beforeinput', handleBeforeInput); + }; + }, [field]); + + useEffect(() => { + if (inputRef.current && cursorPositionRef.current !== null) { + inputRef.current.setSelectionRange( + cursorPositionRef.current, + cursorPositionRef.current, + ); + } + }, [value]); + + return ( + <> + PhoneInputInternals.handleKeyDown(event)} + onChange={e => { + PhoneInputInternals.handleAutoComplete(e, field); + }} + disabled={disabled} + placeholder={placeholder} + autoComplete={autoComplete} + aria-required={ariaRequired} + aria-describedby={ + ariaDescribedBy ? + `${ariaDescribedBy} ${warningMessageId}` + : warningMessageId + } + aria-invalid={ariaInvalid} + className={className} + style={style} + /> + + {warningMessage} + + + ); +} diff --git a/src/components/form-components/phone-input/styles.module.scss b/src/components/form-components/phone-input/styles.module.scss new file mode 100644 index 00000000..b7270b8d --- /dev/null +++ b/src/components/form-components/phone-input/styles.module.scss @@ -0,0 +1,48 @@ +@use '../../../styles/partials' as *; + +.input { + @extend .b1; + display: block; + background-color: transparent; + color: transparent; + border-radius: 0px; + border-top: none; + border-right: none; + border-left: none; + border-bottom: 3px solid $color-black-text; + margin: 0; + padding: 8px 0 8px 0; + width: 100%; + transition: color 250ms linear; + + &:focus { + outline: none; + } + + &::placeholder { + color: transparent; + transition: color 250ms linear; + } +} + +.input:autofill, +.input:-webkit-autofill { + color: $color-black-text; + transition: none; +} + +.show_text { + color: $color-black-text; + + &::placeholder { + color: $color-smoke; + } +} + +.invalid { + border-color: $color-error; +} + +.caution { + border-color: $color-review-light; +} diff --git a/src/components/form-components/select/combobox/combobox.tsx b/src/components/form-components/select/combobox/combobox.tsx index e724c25f..63f9842e 100644 --- a/src/components/form-components/select/combobox/combobox.tsx +++ b/src/components/form-components/select/combobox/combobox.tsx @@ -1,18 +1,19 @@ 'use client'; import { forwardRef, + useId, type ForwardedRef, type KeyboardEventHandler, type RefObject, } from 'react'; import { + Field, usePipe, useMultiPipe, useValue, ValidityUtils, type FieldOfType, type IGroup, - type Field, } from 'fully-formed'; import Image from 'next/image'; import { isPrintableCharacterKey } from '../utils/is-printable-character-key'; @@ -107,12 +108,15 @@ export const Combobox = forwardRef(function Combobox( }); if ( - ValidityUtils.isInvalid(validity) && - (fieldState.hasBeenBlurred || - fieldState.hasBeenModified || - fieldState.submitted) + fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted ) { - classNames.push(styles.invalid); + if (ValidityUtils.isCaution(validity)) { + classNames.push(styles.caution); + } else if (ValidityUtils.isInvalid(validity)) { + classNames.push(styles.invalid); + } } return classNames.join(' '); @@ -131,6 +135,25 @@ export const Combobox = forwardRef(function Combobox( ); }); + const warningMessage = useMultiPipe( + [props.field, ...props.groups], + states => { + const validity = ValidityUtils.minValidity(states); + const fieldState = states[0]; + + return ( + ValidityUtils.isCaution(validity) && + (fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted) + ) ? + 'The value of this field could not be confirmed. Please verify that it is correct.' + : undefined; + }, + ); + + const warningMessageId = useId(); + const handleKeyboardInput: KeyboardEventHandler = event => { const { key } = event; const controlKeys = ['ArrowDown', 'ArrowUp', 'Enter']; @@ -223,7 +246,11 @@ export const Combobox = forwardRef(function Combobox( aria-controls={props.menuId} aria-expanded={false} aria-label={props.label} - aria-describedby={props['aria-describedby']} + aria-describedby={ + props['aria-describedby'] ? + `${props['aria-describedby']} ${warningMessageId}` + : warningMessageId + } aria-invalid={ariaInvalid} aria-required={props['aria-required']} type="text" @@ -233,6 +260,12 @@ export const Combobox = forwardRef(function Combobox( autoComplete="off" readOnly /> + + {warningMessage} + ); }); diff --git a/src/components/form-components/select/combobox/styles.module.scss b/src/components/form-components/select/combobox/styles.module.scss index ebe9f57f..b762b010 100644 --- a/src/components/form-components/select/combobox/styles.module.scss +++ b/src/components/form-components/select/combobox/styles.module.scss @@ -21,6 +21,7 @@ top: 0; left: 0; cursor: default; + max-width: 0; } .combobox { @@ -36,6 +37,10 @@ border-color: $color-error; } +.caution { + border-color: $color-review-light; +} + .selection { overflow: hidden; white-space: nowrap; diff --git a/src/components/form-components/select/menu/styles.module.scss b/src/components/form-components/select/menu/styles.module.scss index 35af7888..00e1dab2 100644 --- a/src/components/form-components/select/menu/styles.module.scss +++ b/src/components/form-components/select/menu/styles.module.scss @@ -7,12 +7,12 @@ position: absolute; top: calc(100% - $border-width); left: 0; + z-index: 1; } .menu { min-width: 100%; width: fit-content; - max-height: 40vh; overflow-y: scroll; list-style-type: none; margin: 0; @@ -32,6 +32,7 @@ @extend .b1; cursor: default; padding: $option-padding; + min-height: $line-height-sm + 2 * $option-padding; } .option:focus { diff --git a/src/components/form-components/select/menu/utils/menu-state/open-menu.ts b/src/components/form-components/select/menu/utils/menu-state/open-menu.ts index 255aa0db..4058a942 100644 --- a/src/components/form-components/select/menu/utils/menu-state/open-menu.ts +++ b/src/components/form-components/select/menu/utils/menu-state/open-menu.ts @@ -45,8 +45,27 @@ export function openMenu({ isKeyboardNavigating.current = true; } + /* + Set the menu height to 0 so it doesn't increase the size of the page + when opened. + */ + menuRef.current.setAttribute('style', 'max-height: 0;'); + + /* + Show the menu. + */ containerRef.current.classList.remove('hidden'); + /* + Set the height of the menu such that it is not larger than the space + between the bottom of the select component and the bottom of the + document. + */ + menuRef.current.setAttribute( + 'style', + `max-height: min(50vh, ${getDistanceToBottomOfScreen(containerRef)}px);`, + ); + focusOnOption({ optionIndex: indexOfOptionToReceiveFocus, optionCount: optionCount, @@ -68,3 +87,9 @@ export function openMenu({ comboboxRef.current?.setAttribute('aria-expanded', 'true'); } } + +function getDistanceToBottomOfScreen(ref: RefObject) { + const element = ref.current!; + const elementTop = element.getBoundingClientRect().top + scrollY; + return document.documentElement.scrollHeight - elementTop; +} diff --git a/src/components/form-components/select/select.tsx b/src/components/form-components/select/select.tsx index 9b390832..07d5a4d0 100644 --- a/src/components/form-components/select/select.tsx +++ b/src/components/form-components/select/select.tsx @@ -6,16 +6,24 @@ import { type ReactNode, type CSSProperties, } from 'react'; -import { usePipe, type Field } from 'fully-formed'; +import { + usePipe, + useMultiPipe, + ValidityUtils, + type Field, + type FieldOfType, + useCancelFocusOnUnmount, +} from 'fully-formed'; +import Image from 'next/image'; import { Combobox } from './combobox'; import { Menu, type MenuRef } from './menu'; import { Messages } from '../messages'; import { WidthSetter } from './width-setter'; -import type { FieldOfType } from 'fully-formed'; +import { MoreInfo } from '@/components/utils/more-info'; import type { Option } from './types/option'; import type { GroupConfigObject } from '../types/group-config-object'; +import warningIconLight from '@/../public/static/images/components/shared/warning-icon-light.svg'; import styles from './styles.module.scss'; -import { MoreInfo } from '@/components/utils/more-info'; interface SelectProps { /** @@ -99,6 +107,7 @@ export function Select({ field, ...groups.filter(group => group.displayMessages).map(({ group }) => group), ]; + const hideMessages = usePipe( field, ({ hasBeenBlurred, hasBeenModified, submitted }) => { @@ -106,6 +115,21 @@ export function Select({ }, ); + const showWarningIcon = useMultiPipe( + [field, ...groups.map(g => g.group)], + states => { + const validity = ValidityUtils.minValidity(states); + const fieldState = states[0]; + + return ( + ValidityUtils.isCaution(validity) && + (fieldState.hasBeenModified || + fieldState.hasBeenBlurred || + fieldState.submitted) + ); + }, + ); + const classNames = [styles.select]; if (className) { @@ -122,7 +146,10 @@ export function Select({ } menuRef.current?.closeMenu(); - field.blur(); + + if (field.state.isInFocus) { + field.blur(); + } } document.addEventListener('click', handleClickOutsideSelect); @@ -131,12 +158,15 @@ export function Select({ document.removeEventListener('click', handleClickOutsideSelect); }, [field]); + useCancelFocusOnUnmount(field); + return (
field.focus()} >
- +
+ {showWarningIcon && ( + Warning Icon + )} + +
); diff --git a/src/components/form-components/select/styles.module.scss b/src/components/form-components/select/styles.module.scss index 656f896e..4a3f2acd 100644 --- a/src/components/form-components/select/styles.module.scss +++ b/src/components/form-components/select/styles.module.scss @@ -1,3 +1,4 @@ +@use '../../../styles/partials' as *; @use './variables' as *; .select { @@ -13,6 +14,16 @@ width: 100%; } +.messages_container { + height: $font-size-xxs + 5px; +} + +.warning_icon { + width: 16.55px; + height: 14px; + margin-top: 4px; +} + .open_more_info { margin-left: $more-info-margin; } diff --git a/src/components/progress/badges/action-badge.tsx b/src/components/progress/badges/action-badge.tsx index 786f6862..2fd6da44 100644 --- a/src/components/progress/badges/action-badge.tsx +++ b/src/components/progress/badges/action-badge.tsx @@ -1,4 +1,4 @@ -import type { ActionBadge } from '@/model/types/action-badge'; +import type { ActionBadge } from '@/model/types/badges/action-badge'; import { Actions } from '@/model/enums/actions'; import styles from './styles.module.scss'; import Image from 'next/image'; diff --git a/src/components/progress/badges/badges.tsx b/src/components/progress/badges/badges.tsx index 69d7c097..077b2dfd 100644 --- a/src/components/progress/badges/badges.tsx +++ b/src/components/progress/badges/badges.tsx @@ -1,4 +1,4 @@ -import type { Badge } from '@/model/types/badge'; +import type { Badge } from '@/model/types/badges/badge'; import { NumberBadge } from './number-badge'; import styles from './styles.module.scss'; import { isActionBadge } from './is-action-badge'; diff --git a/src/components/progress/badges/is-action-badge.ts b/src/components/progress/badges/is-action-badge.ts index ec0dbee9..64558f02 100644 --- a/src/components/progress/badges/is-action-badge.ts +++ b/src/components/progress/badges/is-action-badge.ts @@ -1,5 +1,5 @@ -import type { ActionBadge } from '@/model/types/action-badge'; -import type { PlayerBadge } from '@/model/types/player-badge'; +import type { ActionBadge } from '@/model/types/badges/action-badge'; +import type { PlayerBadge } from '@/model/types/badges/player-badge'; export function isActionBadge( badge: ActionBadge | PlayerBadge, diff --git a/src/components/progress/badges/is-player-badge.ts b/src/components/progress/badges/is-player-badge.ts index 02e335ec..1f0563d9 100644 --- a/src/components/progress/badges/is-player-badge.ts +++ b/src/components/progress/badges/is-player-badge.ts @@ -1,5 +1,5 @@ -import type { ActionBadge } from '@/model/types/action-badge'; -import type { PlayerBadge } from '@/model/types/player-badge'; +import type { ActionBadge } from '@/model/types/badges/action-badge'; +import type { PlayerBadge } from '@/model/types/badges/player-badge'; export function isPlayerBadge( badge: ActionBadge | PlayerBadge, diff --git a/src/components/progress/badges/player-badge.tsx b/src/components/progress/badges/player-badge.tsx index 6c014bc1..b4ea2ea4 100644 --- a/src/components/progress/badges/player-badge.tsx +++ b/src/components/progress/badges/player-badge.tsx @@ -1,4 +1,4 @@ -import type { PlayerBadge } from '@/model/types/player-badge'; +import type { PlayerBadge } from '@/model/types/badges/player-badge'; import { AVATARS } from '@/constants/avatars'; import styles from './styles.module.scss'; import Image from 'next/image'; diff --git a/src/components/utils/loading-wheel/loading-wheel.tsx b/src/components/utils/loading-wheel/loading-wheel.tsx index 3c0f993f..4129b228 100644 --- a/src/components/utils/loading-wheel/loading-wheel.tsx +++ b/src/components/utils/loading-wheel/loading-wheel.tsx @@ -12,6 +12,7 @@ export function LoadingWheel() { height={82} alt="loading" className={styles.spinner} + priority /> diff --git a/src/components/utils/modal/modal.tsx b/src/components/utils/modal/modal.tsx index a0233952..1811f24b 100644 --- a/src/components/utils/modal/modal.tsx +++ b/src/components/utils/modal/modal.tsx @@ -49,6 +49,7 @@ export function Modal({ className={styles.close_btn} aria-label="close dialog" onClick={closeModal} + type="button" > diff --git a/src/components/utils/modal/styles.module.scss b/src/components/utils/modal/styles.module.scss index 8f3cae53..211e5b86 100644 --- a/src/components/utils/modal/styles.module.scss +++ b/src/components/utils/modal/styles.module.scss @@ -34,7 +34,7 @@ $animation-timing: 400ms; } .content { - margin: 16px 36.5px 36.5px 48px; + margin: 16px 36.5px 48px 36.5px; width: 225px; text-align: center; } diff --git a/src/constants/us-state-abbreviations.ts b/src/constants/us-state-abbreviations.ts new file mode 100644 index 00000000..4d1dd741 --- /dev/null +++ b/src/constants/us-state-abbreviations.ts @@ -0,0 +1,53 @@ +export const US_STATE_ABBREVIATIONS = { + ALASKA: 'AK', + ALABAMA: 'AL', + ARKANSAS: 'AR', + ARIZONA: 'AZ', + CALIFORNIA: 'CA', + COLORADO: 'CO', + CONNECTICUT: 'CT', + DELAWARE: 'DE', + DISTRICT_OF_COLUMBIA: 'DC', + FLORIDA: 'FL', + GEORGIA: 'GA', + HAWAII: 'HI', + IOWA: 'IA', + IDAHO: 'ID', + ILLINOIS: 'IL', + INDIANA: 'IN', + KANSAS: 'KS', + KENTUCKY: 'KY', + LOUISIANA: 'LA', + MASSACHUSETTS: 'MA', + MARYLAND: 'MD', + MAINE: 'ME', + MICHIGAN: 'MI', + MINNESOTA: 'MN', + MISSOURI: 'MO', + MISSISSIPPI: 'MS', + MONTANA: 'MT', + NORTH_CAROLINA: 'NC', + NORTH_DAKOTA: 'ND', + NEBRASKA: 'NE', + NEW_HAMPSHIRE: 'NH', + NEW_JERSEY: 'NJ', + NEW_MEXICO: 'NM', + NEVADA: 'NV', + NEW_YORK: 'NY', + OHIO: 'OH', + OKLAHOMA: 'OK', + OREGON: 'OR', + PENNSYLVANIA: 'PA', + RHODE_ISLAND: 'RI', + SOUTH_CAROLINA: 'SC', + SOUTH_DAKOTA: 'SD', + TENNESSEE: 'TN', + TEXAS: 'TX', + UTAH: 'UT', + VIRGINIA: 'VA', + VERMONT: 'VT', + WASHINGTON: 'WA', + WISCONSIN: 'WI', + WEST_VIRGINIA: 'WV', + WYOMING: 'WY', +}; diff --git a/src/constants/us-states-and-territories.ts b/src/constants/us-states-and-territories.ts deleted file mode 100644 index 1732111b..00000000 --- a/src/constants/us-states-and-territories.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const US_STATES_AND_TERRITORIES = [ - 'AL', - 'AK', - 'AS', - 'AZ', - 'AR', - 'CA', - 'CO', - 'CT', - 'DE', - 'DC', - 'FM', - 'FL', - 'GA', - 'GU', - 'HI', - 'ID', - 'IL', - 'IN', - 'IA', - 'KS', - 'KY', - 'LA', - 'ME', - 'MH', - 'MD', - 'MA', - 'MI', - 'MN', - 'MS', - 'MO', - 'MT', - 'NE', - 'NV', - 'NH', - 'NJ', - 'NM', - 'NY', - 'NC', - 'ND', - 'MP', - 'OH', - 'OK', - 'OR', - 'PW', - 'PA', - 'PR', - 'RI', - 'SC', - 'SD', - 'TN', - 'TX', - 'UT', - 'VT', - 'VI', - 'VA', - 'WA', - 'WV', - 'WI', - 'WY', -]; diff --git a/src/contexts/user-context/client-side-user-context-provider.tsx b/src/contexts/user-context/client-side-user-context-provider.tsx index b56c00ac..40afcb90 100644 --- a/src/contexts/user-context/client-side-user-context-provider.tsx +++ b/src/contexts/user-context/client-side-user-context-provider.tsx @@ -8,6 +8,8 @@ import { UserContext, } from './user-context'; import { clearInviteCode } from './clear-invite-code-cookie'; +import { clearAllPersistentFormElements, ValueOf } from 'fully-formed'; +import { VoterRegistrationForm } from '@/app/register/voter-registration-form'; import type { User } from '@/model/types/user'; import type { InvitedBy } from '@/model/types/invited-by'; @@ -130,6 +132,7 @@ export function ClientSideUserContextProvider( setUser(null); setInvitedBy(null); + clearAllPersistentFormElements(); } /* istanbul ignore next */ @@ -137,6 +140,17 @@ export function ClientSideUserContextProvider( throw new Error('not implemented.'); } + /* istanbul ignore next */ + async function registerToVote( + formData: ValueOf>, + ): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error('not implemented.')); + }, 3000); + }); + } + return ( {props.children} diff --git a/src/contexts/user-context/user-context.tsx b/src/contexts/user-context/user-context.tsx index ff997d3a..b3375a21 100644 --- a/src/contexts/user-context/user-context.tsx +++ b/src/contexts/user-context/user-context.tsx @@ -1,7 +1,9 @@ 'use client'; import { createNamedContext } from '../../hooks/create-named-context'; +import { VoterRegistrationForm } from '@/app/register/voter-registration-form'; import type { User } from '../../model/types/user'; import type { Avatar } from '@/model/types/avatar'; +import type { ValueOf } from 'fully-formed'; import type { InvitedBy } from '@/model/types/invited-by'; interface SignUpWithEmailParams { @@ -30,6 +32,9 @@ interface UserContextType { signInWithOTP(params: SignInWithOTPParams): Promise; signOut(): Promise; restartChallenge(): Promise; + registerToVote( + formData: ValueOf>, + ): Promise; gotElectionReminders(): Promise; } diff --git a/src/hooks/use-prefetch.ts b/src/hooks/use-prefetch.ts new file mode 100644 index 00000000..a14af4de --- /dev/null +++ b/src/hooks/use-prefetch.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import type { PrefetchOptions } from 'next/dist/shared/lib/app-router-context.shared-runtime'; +import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; + +/** + * Prefetches JavaScript for a given route using the Next.js App Router. + * + * @param href - The path of the route to prefetch. + * @param options - {@link PrefetchOptions}. + * + * @remarks + * Use this when a component calls the `push` method of an {@link AppRouterInstance}. + */ +export function usePrefetch(href: string, options?: PrefetchOptions) { + const router = useRouter(); + + useEffect(() => { + router.prefetch(href, options); + }, [href, options, router]); +} diff --git a/src/hooks/use-scroll-to-top.ts b/src/hooks/use-scroll-to-top.ts new file mode 100644 index 00000000..467ab1cc --- /dev/null +++ b/src/hooks/use-scroll-to-top.ts @@ -0,0 +1,10 @@ +import { useEffect } from 'react'; + +/** + * Scrolls to the top of the screen when the calling component mounts. + */ +export function useScrollToTop() { + useEffect(() => { + window.scrollTo(0, 0); + }, []); +} diff --git a/src/model/types/addresses/address-component.ts b/src/model/types/addresses/address-component.ts new file mode 100644 index 00000000..5f3fc581 --- /dev/null +++ b/src/model/types/addresses/address-component.ts @@ -0,0 +1,4 @@ +export interface AddressComponent { + value: string; + hasIssue: boolean; +} diff --git a/src/model/types/addresses/address-components.ts b/src/model/types/addresses/address-components.ts new file mode 100644 index 00000000..3651a15d --- /dev/null +++ b/src/model/types/addresses/address-components.ts @@ -0,0 +1,9 @@ +import { AddressComponent } from './address-component'; + +export interface AddressComponents { + streetLine1: AddressComponent; + streetLine2?: AddressComponent; + city: AddressComponent; + state: AddressComponent; + zip: AddressComponent; +} diff --git a/src/model/types/addresses/address-error-types.ts b/src/model/types/addresses/address-error-types.ts new file mode 100644 index 00000000..c7f51421 --- /dev/null +++ b/src/model/types/addresses/address-error-types.ts @@ -0,0 +1,6 @@ +export enum AddressErrorTypes { + UnconfirmedComponents, + ReviewRecommendedAddress, + MissingSubpremise, + ValidationFailed, +} diff --git a/src/model/types/addresses/address-errors.ts b/src/model/types/addresses/address-errors.ts new file mode 100644 index 00000000..a964a808 --- /dev/null +++ b/src/model/types/addresses/address-errors.ts @@ -0,0 +1,10 @@ +import type { UnconfirmedComponentsError } from './unconfirmed-components-error'; +import type { ReviewRecommendedAddressError } from './review-recommended-address-error'; +import type { MissingSubpremiseError } from './missing-subpremise-error'; +import type { ValidationFailedError } from './validation-failed-error'; + +export type AddressErrors = + | UnconfirmedComponentsError + | ReviewRecommendedAddressError + | MissingSubpremiseError + | ValidationFailedError; diff --git a/src/model/types/addresses/address-form-names.ts b/src/model/types/addresses/address-form-names.ts new file mode 100644 index 00000000..9fbe1660 --- /dev/null +++ b/src/model/types/addresses/address-form-names.ts @@ -0,0 +1,5 @@ +import { AddressesForm } from '@/app/register/addresses/addresses-form'; + +export type AddressFormNames = keyof InstanceType< + typeof AddressesForm +>['fields']; diff --git a/src/model/types/addresses/address.ts b/src/model/types/addresses/address.ts new file mode 100644 index 00000000..23668c2b --- /dev/null +++ b/src/model/types/addresses/address.ts @@ -0,0 +1,7 @@ +export interface Address { + streetLine1: string; + streetLine2?: string; + city: string; + state: string; + zip: string; +} diff --git a/src/model/types/addresses/missing-subpremise-error.ts b/src/model/types/addresses/missing-subpremise-error.ts new file mode 100644 index 00000000..7d7ae0d8 --- /dev/null +++ b/src/model/types/addresses/missing-subpremise-error.ts @@ -0,0 +1,7 @@ +import type { AddressErrorTypes } from './address-error-types'; +import type { AddressFormNames } from './address-form-names'; + +export interface MissingSubpremiseError { + type: AddressErrorTypes.MissingSubpremise; + form: AddressFormNames; +} diff --git a/src/model/types/addresses/review-recommended-address-error.ts b/src/model/types/addresses/review-recommended-address-error.ts new file mode 100644 index 00000000..5ed53fcc --- /dev/null +++ b/src/model/types/addresses/review-recommended-address-error.ts @@ -0,0 +1,10 @@ +import type { AddressErrorTypes } from './address-error-types'; +import type { AddressFormNames } from './address-form-names'; +import type { AddressComponents } from './address-components'; + +export interface ReviewRecommendedAddressError { + type: AddressErrorTypes.ReviewRecommendedAddress; + form: AddressFormNames; + enteredAddress: AddressComponents; + recommendedAddress: AddressComponents; +} diff --git a/src/model/types/addresses/unconfirmed-components-error.ts b/src/model/types/addresses/unconfirmed-components-error.ts new file mode 100644 index 00000000..cf6cda03 --- /dev/null +++ b/src/model/types/addresses/unconfirmed-components-error.ts @@ -0,0 +1,9 @@ +import type { AddressErrorTypes } from './address-error-types'; +import type { AddressComponents } from './address-components'; +import type { AddressFormNames } from './address-form-names'; + +export interface UnconfirmedComponentsError { + type: AddressErrorTypes.UnconfirmedComponents; + form: AddressFormNames; + unconfirmedAddressComponents: AddressComponents; +} diff --git a/src/model/types/addresses/validation-failed-error.ts b/src/model/types/addresses/validation-failed-error.ts new file mode 100644 index 00000000..f5beb78f --- /dev/null +++ b/src/model/types/addresses/validation-failed-error.ts @@ -0,0 +1,5 @@ +import type { AddressErrorTypes } from './address-error-types'; + +export interface ValidationFailedError { + type: AddressErrorTypes.ValidationFailed; +} diff --git a/src/model/types/action-badge.ts b/src/model/types/badges/action-badge.ts similarity index 73% rename from src/model/types/action-badge.ts rename to src/model/types/badges/action-badge.ts index 361ff003..604eb2fe 100644 --- a/src/model/types/action-badge.ts +++ b/src/model/types/badges/action-badge.ts @@ -1,4 +1,4 @@ -import { Actions } from '../enums/actions'; +import { Actions } from '../../enums/actions'; /** * Represents a badge awarded to the user through their own actions. diff --git a/src/model/types/badge.ts b/src/model/types/badges/badge.ts similarity index 100% rename from src/model/types/badge.ts rename to src/model/types/badges/badge.ts diff --git a/src/model/types/player-badge.ts b/src/model/types/badges/player-badge.ts similarity index 83% rename from src/model/types/player-badge.ts rename to src/model/types/badges/player-badge.ts index 0202b836..15718473 100644 --- a/src/model/types/player-badge.ts +++ b/src/model/types/badges/player-badge.ts @@ -1,4 +1,4 @@ -import { Avatar } from './avatar'; +import { Avatar } from '../avatar'; /** * Represents a badge awarded to the user due to an action taken by a player diff --git a/src/model/types/reward.type.ts b/src/model/types/reward.type.ts deleted file mode 100644 index d720c86a..00000000 --- a/src/model/types/reward.type.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface Reward { - businessDescription: string; - businessLink: string; - businessType: string; - locationDescription: 'Online' | 'In Person'; - locationType: 'Online' | 'In Person'; - logo: string; - name: string; - redemptionDescription: string; - rewardAvailable: boolean; - rewardConditions: string; - rewardDescription: string; - rewardEndDate: Date | undefined; - rewardLink: string; - rewardStartDate: Date; - rewardType: 'Online' | 'In Person'; -} diff --git a/src/model/types/user.ts b/src/model/types/user.ts index d2661eea..3b34e203 100644 --- a/src/model/types/user.ts +++ b/src/model/types/user.ts @@ -1,6 +1,6 @@ import { UserType } from '../enums/user-type'; import type { Avatar } from './avatar'; -import type { Badge } from './badge'; +import type { Badge } from './badges/badge'; export interface User { uid: string; diff --git a/src/services/server/container.ts b/src/services/server/container.ts index 3356ca82..11e01dee 100644 --- a/src/services/server/container.ts +++ b/src/services/server/container.ts @@ -13,6 +13,8 @@ import { redirectIfSignedOutFromSupabase } from './redirect-if-signed-out/redire import { refreshSupabaseSession } from './refresh-session/refresh-supabase-session'; import { SupabaseUserRepository } from './user-repository/supabase-user-repository'; import { WebCryptoSubtleEncryptor } from './encryptor/web-crypto-subtle-encryptor'; +import { MockUSStateInformation } from './us-state-information/mock-us-state-information'; +import { validateAddressesWithGoogleMaps } from './validate-addresses/validate-addresses-with-google-maps'; import { SupabaseVoterRegistrationDataRepository } from './voter-registration-data-repository/supabase-voter-registration-data-repository'; import { createSupabaseServiceRoleClient } from './create-supabase-client/create-supabase-service-role-client'; import { setInviteCodeCookie } from './set-invite-code-cookie/set-invite-code-cookie'; @@ -71,6 +73,11 @@ export const serverContainer = ContainerBuilder.createBuilder() .registerFunction(SERVER_SERVICE_KEYS.refreshSession, refreshSupabaseSession) .registerClass(SERVER_SERVICE_KEYS.UserRepository, SupabaseUserRepository) .registerClass(SERVER_SERVICE_KEYS.Encryptor, WebCryptoSubtleEncryptor) + .registerClass(SERVER_SERVICE_KEYS.USStateInformation, MockUSStateInformation) + .registerFunction( + SERVER_SERVICE_KEYS.validateAddresses, + validateAddressesWithGoogleMaps, + ) .registerFunction( SERVER_SERVICE_KEYS.setInviteCodeCookie, setInviteCodeCookie, diff --git a/src/services/server/create-supabase-client/create-supabase-ssr-client.ts b/src/services/server/create-supabase-client/create-supabase-ssr-client.ts index d8a03205..7622b69d 100644 --- a/src/services/server/create-supabase-client/create-supabase-ssr-client.ts +++ b/src/services/server/create-supabase-client/create-supabase-ssr-client.ts @@ -20,11 +20,11 @@ import type { SupabaseClient } from '@supabase/supabase-js'; * To bypass RLS in repository-type classes, use {@link createSupabaseServiceRoleClient}. */ export const createSupabaseSSRClient = bind( - function createSupabaseServerClient() { + function createSupabaseServerClient() { const cookieStore = cookies(); const { NEXT_PUBLIC_SUPABASE_URL: url } = PUBLIC_ENVIRONMENT_VARIABLES; - - const { SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey } = + + const { SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey } = PRIVATE_ENVIRONMENT_VARIABLES; return createServerClient(url, serviceRoleKey, { diff --git a/src/services/server/keys.ts b/src/services/server/keys.ts index 15b0edad..0a9dc909 100644 --- a/src/services/server/keys.ts +++ b/src/services/server/keys.ts @@ -9,6 +9,8 @@ import type { IUserRecordParser } from './user-record-parser/i-user-record-parse import type { Encryptor } from './encryptor/encryptor'; import type { VoterRegistrationDataRepository } from './voter-registration-data-repository/voter-registration-data-repository'; import type { CreateSupabaseClient } from './create-supabase-client/create-supabase-client'; +import type { USStateInformation } from './us-state-information/us-state-information'; +import type { ValidateAddresses } from './validate-addresses/validate-addresses'; import type { InvitationsRepository } from './invitations-repository/invitations-repository'; const { keys } = Keys.createKeys() @@ -38,6 +40,10 @@ const { keys } = Keys.createKeys() .forType() .addKey('Encryptor') .forType() + .addKey('USStateInformation') + .forType() + .addKey('validateAddresses') + .forType() .addKey('setInviteCodeCookie') .forType() .addKey('InvitationsRepository') diff --git a/src/services/server/us-state-information/mock-data.ts b/src/services/server/us-state-information/mock-data.ts new file mode 100644 index 00000000..5152c2c7 --- /dev/null +++ b/src/services/server/us-state-information/mock-data.ts @@ -0,0 +1,346 @@ +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; + +enum Parties { + Alliance = 'Alliance Party', + Constitution = 'Constitution Party', + Democratic = 'Democratic Party', + Green = 'Green Party', + Libertarian = 'Libertarian Party', + Republican = 'Republican Party', + Unity = 'Unity Party of America', + WorkingClass = 'Working Class Party', + WorkingFamilies = 'Working Families Party', + Independent = 'None (No Affiliation)', +} + +/** + * Mock data sourced from https://en.wikipedia.org/wiki/Political_parties_in_the_United_States + * which can be used to simulate data returned from the RTC Rock API + * /state_requirements endpoint until we are able to integrate it. + */ +export const MockData = { + [US_STATE_ABBREVIATIONS.ALABAMA]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.ALASKA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.ARIZONA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.ARKANSAS]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.CALIFORNIA]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.COLORADO]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Unity, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.CONNECTICUT]: [ + Parties.Alliance, + Parties.Democratic, + Parties.Green, + Parties.Republican, + Parties.WorkingFamilies, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.DELAWARE]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.DISTRICT_OF_COLUMBIA]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.FLORIDA]: [ + Parties.Alliance, + Parties.Constitution, + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Unity, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.GEORGIA]: [Parties.Democratic, Parties.Republican], + [US_STATE_ABBREVIATIONS.HAWAII]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.IDAHO]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.ILLINOIS]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.INDIANA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.IOWA]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.KANSAS]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.KENTUCKY]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.LOUISIANA]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MAINE]: [ + Parties.Democratic, + Parties.Green, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MARYLAND]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MASSACHUSETTS]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MICHIGAN]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.WorkingClass, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MINNESOTA]: [ + Parties.Alliance, + Parties.Democratic, + Parties.Green, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MISSISSIPPI]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MISSOURI]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.MONTANA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NEBRASKA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NEVADA]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NEW_HAMPSHIRE]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NEW_JERSEY]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NEW_MEXICO]: [ + Parties.Democratic, + Parties.Republican, + Parties.WorkingFamilies, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NEW_YORK]: [ + Parties.Democratic, + Parties.Republican, + Parties.WorkingFamilies, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NORTH_CAROLINA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.NORTH_DAKOTA]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.OHIO]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.OKLAHOMA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.OREGON]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.WorkingFamilies, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.PENNSYLVANIA]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.RHODE_ISLAND]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.SOUTH_CAROLINA]: [ + Parties.Alliance, + Parties.Constitution, + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.WorkingFamilies, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.SOUTH_DAKOTA]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.TENNESSEE]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.TEXAS]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.UTAH]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.VERMONT]: [ + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.VIRGINIA]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.WASHINGTON]: [ + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.WEST_VIRGINIA]: [ + Parties.Democratic, + Parties.Green, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.WISCONSIN]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Republican, + Parties.Independent, + ], + [US_STATE_ABBREVIATIONS.WYOMING]: [ + Parties.Constitution, + Parties.Democratic, + Parties.Libertarian, + Parties.Republican, + Parties.Independent, + ], +}; diff --git a/src/services/server/us-state-information/mock-us-state-information.ts b/src/services/server/us-state-information/mock-us-state-information.ts new file mode 100644 index 00000000..75bcf65f --- /dev/null +++ b/src/services/server/us-state-information/mock-us-state-information.ts @@ -0,0 +1,26 @@ +import 'server-only'; +import { inject } from 'undecorated-di'; +import { MockData } from './mock-data'; +import type { USStateInformation } from './us-state-information'; + +/** + * An implementation of USStateInformation that provides state-specific + * information sourced from mock data. Can be used to approximate the + * values returned by the RTV Rock API /state_requirements endpoint until + * such time as we are able to integrate it. + */ +export const MockUSStateInformation = inject( + class MockUSStateInformation implements USStateInformation { + getBallotQualifiedPoliticalPartiesByLocation( + state: string, + zip: string, + ): Promise { + if (!(state in MockData)) { + return Promise.resolve([]); + } + + return Promise.resolve(MockData[state]); + } + }, + [], +); diff --git a/src/services/server/us-state-information/us-state-information.ts b/src/services/server/us-state-information/us-state-information.ts new file mode 100644 index 00000000..ac52bf37 --- /dev/null +++ b/src/services/server/us-state-information/us-state-information.ts @@ -0,0 +1,6 @@ +export interface USStateInformation { + getBallotQualifiedPoliticalPartiesByLocation( + state: string, + zip: string, + ): Promise; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/index.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/index.ts new file mode 100644 index 00000000..019e967c --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/index.ts @@ -0,0 +1 @@ +export { validateAddressesWithGoogleMaps } from './validate-addresses-with-google-maps'; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/address-component.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/address-component.ts new file mode 100644 index 00000000..b4977dc9 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/address-component.ts @@ -0,0 +1,12 @@ +import type { NullablePartial } from './nullable-partial'; +import type { ComponentName } from './component-name'; + +export type AddressComponent = NullablePartial<{ + componentName: ComponentName; + componentType: string; + confirmationLevel: any; + inferred: boolean; + spellCorrected: boolean; + replaced: boolean; + unexpected: boolean; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/address.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/address.ts new file mode 100644 index 00000000..aeb6fedc --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/address.ts @@ -0,0 +1,12 @@ +import type { NullablePartial } from './nullable-partial'; +import type { PostalAddress } from './postal-address'; +import type { AddressComponent } from './address-component'; + +export type Address = NullablePartial<{ + formattedAddress: string; + postalAddress: PostalAddress; + addressComponents: AddressComponent[]; + missingComponentTypes: string[]; + unconfirmedComponentTypes: string[]; + unresolvedTokens: string[]; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/component-name.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/component-name.ts new file mode 100644 index 00000000..56a20f50 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/component-name.ts @@ -0,0 +1,6 @@ +import type { NullablePartial } from './nullable-partial'; + +export type ComponentName = NullablePartial<{ + text: string; + languageCode: string; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/geocode.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/geocode.ts new file mode 100644 index 00000000..72d7f5e1 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/geocode.ts @@ -0,0 +1,13 @@ +import type { NullablePartial } from './nullable-partial'; +import type { LatLng } from './lat-lng'; +import type { PlusCode } from './plus-code'; +import type { Viewport } from './viewport'; + +export type Geocode = NullablePartial<{ + location: LatLng; + plusCode: PlusCode; + bounds: Viewport; + featureSizeMeters: number; + placeId: string; + placeTypes: string[]; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/ivalidate-address-response.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/ivalidate-address-response.ts new file mode 100644 index 00000000..1ca1002d --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/ivalidate-address-response.ts @@ -0,0 +1,9 @@ +import type { NullablePartial } from './nullable-partial'; +import type { ValidationResult } from './validation-result'; + +export type IValidateAddressResponse = NullablePartial<{ + result: ValidationResult | null; + responseId: string; +}>; + +type x = IValidateAddressResponse['result']; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/lat-lng.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/lat-lng.ts new file mode 100644 index 00000000..147be9d0 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/lat-lng.ts @@ -0,0 +1,6 @@ +import type { NullablePartial } from './nullable-partial'; + +export type LatLng = NullablePartial<{ + latitude: number; + longitude: number; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/metadata.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/metadata.ts new file mode 100644 index 00000000..65b2ef83 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/metadata.ts @@ -0,0 +1,7 @@ +import type { NullablePartial } from './nullable-partial'; + +export type Metadata = NullablePartial<{ + business: boolean; + poBox: boolean; + residential: boolean; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/nullable-partial.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/nullable-partial.ts new file mode 100644 index 00000000..b5c7a9dd --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/nullable-partial.ts @@ -0,0 +1,3 @@ +export type NullablePartial = { + [K in keyof T]+?: T[K] | null; +}; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/plus-code.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/plus-code.ts new file mode 100644 index 00000000..b3e9a5b8 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/plus-code.ts @@ -0,0 +1,6 @@ +import type { NullablePartial } from './nullable-partial'; + +export type PlusCode = NullablePartial<{ + globalCode: string; + compoundCode: string; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/postal-address.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/postal-address.ts new file mode 100644 index 00000000..3d740b69 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/postal-address.ts @@ -0,0 +1,15 @@ +import type { NullablePartial } from './nullable-partial'; + +export type PostalAddress = NullablePartial<{ + revision: number; + regionCode: string; + languageCode: string; + postalCode: string; + sortingCode: string; + administrativeArea: string; + locality: string; + sublocality: string; + addressLines: string[]; + recipients: string[]; + organization: string; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/processable-response.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/processable-response.ts new file mode 100644 index 00000000..aea41114 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/processable-response.ts @@ -0,0 +1,23 @@ +export interface ProcessableResponse { + result: { + verdict: { + hasUnconfirmedComponents?: boolean; + }; + address: { + postalAddress: { + postalCode: string; + administrativeArea: string; + locality: string; + addressLines: string[]; + }; + addressComponents: Array<{ + componentName: { + text: string; + }; + componentType: string; + confirmationLevel: string; + }>; + missingComponentTypes?: string[]; + }; + }; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/usps-address.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/usps-address.ts new file mode 100644 index 00000000..27629ebb --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/usps-address.ts @@ -0,0 +1,13 @@ +import { NullablePartial } from './nullable-partial'; + +export type USPSAddress = NullablePartial<{ + firstAddressLine: string; + firm: string; + secondAddressLine: string; + urbanization: string; + cityStateZipAddressLine: string; + city: string; + state: string; + zipCode: string; + zipCodeExtension: string; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/usps-data.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/usps-data.ts new file mode 100644 index 00000000..02f345e2 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/usps-data.ts @@ -0,0 +1,42 @@ +import type { NullablePartial } from './nullable-partial'; +import type { USPSAddress } from './usps-address'; + +export type USPSData = NullablePartial<{ + standardizedAddress: USPSAddress; + deliveryPointCode: string; + deliveryPointCheckDigit: string; + dpvConfirmation: string; + dpvFootnote: string; + dpvCmra: string; + dpvVacant: string; + dpvNoStat: string; + dpvNoStatReasonCode: number; + dpvDrop: string; + dpvThrowback: string; + dpvNonDeliveryDays: string; + dpvNonDeliveryDaysValues: number; + dpvNoSecureLocation: string; + dpvPbsa: string; + dpvDoorNotAccessible: string; + dpvEnhancedDeliveryCode: string; + carrierRoute: string; + carrierRouteIndicator: string; + ewsNoMatch: boolean; + postOfficeCity: string; + postOfficeState: string; + abbreviatedCity: string; + fipsCountyCode: string; + county: string; + elotNumber: string; + elotFlag: string; + lacsLinkReturnCode: string; + lacsLinkIndicator: string; + poBoxOnlyPostalCode: boolean; + suitelinkFootnote: string; + pmbDesignator: string; + pmbNumber: string; + addressRecordType: string; + defaultAddress: boolean; + errorMessage: string; + cassProcessed: boolean; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/validation-result.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/validation-result.ts new file mode 100644 index 00000000..181b7079 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/validation-result.ts @@ -0,0 +1,15 @@ +import type { NullablePartial } from './nullable-partial'; +import type { Address } from './address'; +import type { Geocode } from './geocode'; +import type { Metadata } from './metadata'; +import type { USPSData } from './usps-data'; +import type { Verdict } from './verdict'; + +export type ValidationResult = NullablePartial<{ + verdict: Verdict; + address: Address; + geocode: Geocode; + metadata: Metadata; + uspsData: USPSData; + englishLatinAddress: Address; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/verdict.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/verdict.ts new file mode 100644 index 00000000..9648d15c --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/verdict.ts @@ -0,0 +1,11 @@ +import type { NullablePartial } from './nullable-partial'; + +export type Verdict = NullablePartial<{ + inputGranularity: any; + validationGranularity: any; + geocodeGranularity: any; + addressComplete: boolean; + hasUnconfirmedComponents: boolean; + hasInferredComponents: boolean; + hasReplacedComponents: boolean; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/viewport.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/viewport.ts new file mode 100644 index 00000000..ffaa12c1 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/types/viewport.ts @@ -0,0 +1,7 @@ +import type { NullablePartial } from './nullable-partial'; +import type { LatLng } from './lat-lng'; + +export type Viewport = NullablePartial<{ + low: LatLng; + high: LatLng; +}>; diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-missing-subpremise-error.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-missing-subpremise-error.ts new file mode 100644 index 00000000..cff269e8 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-missing-subpremise-error.ts @@ -0,0 +1,12 @@ +import { AddressErrorTypes } from '@/model/types/addresses/address-error-types'; +import type { MissingSubpremiseError } from '@/model/types/addresses/missing-subpremise-error'; +import type { AddressFormNames } from '@/model/types/addresses/address-form-names'; + +export function createMissingSubpremiseError( + form: AddressFormNames, +): MissingSubpremiseError { + return { + type: AddressErrorTypes.MissingSubpremise, + form, + }; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-review-recommended-address-error.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-review-recommended-address-error.ts new file mode 100644 index 00000000..3e221451 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-review-recommended-address-error.ts @@ -0,0 +1,81 @@ +import { AddressErrorTypes } from '@/model/types/addresses/address-error-types'; +import type { ReviewRecommendedAddressError } from '@/model/types/addresses/review-recommended-address-error'; +import type { Address } from '@/model/types/addresses/address'; +import type { ProcessableResponse } from '../types/processable-response'; +import type { AddressFormNames } from '@/model/types/addresses/address-form-names'; +import type { AddressComponents } from '@/model/types/addresses/address-components'; + +export function createReviewRecommendedAddressError( + address: Address, + response: ProcessableResponse, + form: AddressFormNames, +): ReviewRecommendedAddressError { + const recommendedAddress = getRecommendedAddressFromResult(response); + + return { + type: AddressErrorTypes.ReviewRecommendedAddress, + form, + enteredAddress: compareAddressesAndCreateAddressComponents( + address, + recommendedAddress, + ), + recommendedAddress: compareAddressesAndCreateAddressComponents( + recommendedAddress, + address, + ), + }; +} + +function getRecommendedAddressFromResult( + response: ProcessableResponse, +): Address { + const { address } = response.result; + + const recommendedAddress: Address = { + streetLine1: address.postalAddress.addressLines[0], + city: address.postalAddress.locality, + state: address.postalAddress.administrativeArea, + zip: address.postalAddress.postalCode.slice(0, 5), + }; + + const streetLine2 = address.postalAddress.addressLines[1]; + + if (streetLine2) { + recommendedAddress.streetLine2 = streetLine2; + } + + return recommendedAddress; +} + +function compareAddressesAndCreateAddressComponents( + addressA: Address, + addressB: Address, +): AddressComponents { + const addressComponents: AddressComponents = { + streetLine1: { + value: addressA.streetLine1, + hasIssue: addressA.streetLine1 !== addressB.streetLine1, + }, + city: { + value: addressA.city, + hasIssue: addressA.city !== addressB.city, + }, + state: { + value: addressA.state, + hasIssue: addressA.state !== addressB.state, + }, + zip: { + value: addressA.zip, + hasIssue: addressA.zip !== addressB.zip, + }, + }; + + if (addressA.streetLine2) { + addressComponents.streetLine2 = { + value: addressA.streetLine2, + hasIssue: addressA.streetLine2 !== addressB.streetLine2, + }; + } + + return addressComponents; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-unconfirmed-components-error.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-unconfirmed-components-error.ts new file mode 100644 index 00000000..e9813196 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/create-unconfirmed-components-error.ts @@ -0,0 +1,153 @@ +import { AddressErrorTypes } from '@/model/types/addresses/address-error-types'; +import { collapseWhitespace } from '@/utils/shared/collapse-whitespace'; +import type { Address } from '@/model/types/addresses/address'; +import type { UnconfirmedComponentsError } from '@/model/types/addresses/unconfirmed-components-error'; +import type { AddressComponents } from '@/model/types/addresses/address-components'; +import type { ProcessableResponse } from '../types/processable-response'; +import type { AddressFormNames } from '@/model/types/addresses/address-form-names'; + +export function createUnconfirmedComponentsError( + address: Address, + response: ProcessableResponse, + form: AddressFormNames, +): UnconfirmedComponentsError { + const unconfirmedAddressComponents: AddressComponents = { + streetLine1: { + value: address.streetLine1, + hasIssue: isAddressLineUnconfirmed(address.streetLine1, response), + }, + city: { + value: address.city, + hasIssue: isComponentUnconfirmed('city', response), + }, + state: { + value: address.state, + hasIssue: isComponentUnconfirmed('state', response), + }, + zip: { + value: address.zip, + hasIssue: isComponentUnconfirmed('zip', response), + }, + }; + + if (address.streetLine2) { + unconfirmedAddressComponents.streetLine2 = { + value: address.streetLine2, + hasIssue: isAddressLineUnconfirmed(address.streetLine2, response), + }; + } + + return { + type: AddressErrorTypes.UnconfirmedComponents, + form, + unconfirmedAddressComponents, + }; +} + +function isAddressLineUnconfirmed( + addressLine: string, + response: ProcessableResponse, +): boolean { + if (addressLineMatchesUnconfirmedStreetAddress(addressLine, response)) { + return true; + } + + const componentsToIgnore = [ + 'street_number', + 'route', + 'locality', + 'administrative_area_level_1', + 'postal_code', + 'postal_code_prefix', + 'postal_code_suffix', + 'country', + ]; + + for (const addressComponent of response.result.address.addressComponents) { + if ( + addressComponent.confirmationLevel === 'CONFIRMED' || + componentsToIgnore.includes(addressComponent.componentType) + ) { + continue; + } + + if (addressComponent.componentName.text === addressLine) { + return true; + } + } + + return false; +} + +function addressLineMatchesUnconfirmedStreetAddress( + addressLine: string, + response: ProcessableResponse, +) { + const streetAddress = getStreetAddressFromResponse(response); + if (!streetAddress) return false; + + return ( + streetAddressIsUnconfirmed(response) && + collapseWhitespace(addressLine) === collapseWhitespace(streetAddress) + ); +} + +function streetAddressIsUnconfirmed(response: ProcessableResponse) { + const streetNumber = response.result.address.addressComponents.find( + component => component.componentType === 'street_number', + ); + + const route = response.result.address.addressComponents.find( + component => component.componentType === 'route', + ); + + const isUnconfirmed = !!( + streetNumber?.confirmationLevel !== 'CONFIRMED' || + route?.confirmationLevel !== 'CONFIRMED' + ); + + return isUnconfirmed; +} + +function getStreetAddressFromResponse( + response: ProcessableResponse, +): string | undefined { + const streetNumber = response.result.address.addressComponents.find( + component => component.componentType === 'street_number', + ); + + const route = response.result.address.addressComponents.find( + component => component.componentType === 'route', + ); + + if (streetNumber && route) { + return `${streetNumber.componentName.text} ${route.componentName.text}`; + } else { + return streetNumber?.componentName.text || route?.componentName.text; + } +} + +function isComponentUnconfirmed( + type: 'city' | 'state' | 'zip', + response: ProcessableResponse, +) { + let componentType = ''; + + switch (type) { + case 'city': + componentType = 'locality'; + break; + case 'state': + componentType = 'administrative_area_level_1'; + break; + case 'zip': + componentType = 'postal_code'; + break; + } + + const component = response.result.address.addressComponents.find( + component => component.componentType === componentType, + ); + + return !component || component.confirmationLevel !== 'CONFIRMED'; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/get-address-lines.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/get-address-lines.ts new file mode 100644 index 00000000..e9c9c030 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/get-address-lines.ts @@ -0,0 +1,17 @@ +import type { Address } from '@/model/types/addresses/address'; + +export function getAddressLines(address: Address): string[] { + let addressLines = [address.streetLine1]; + + if (address.streetLine2) { + addressLines.push(address.streetLine2); + } + + addressLines = addressLines.concat([ + address.city, + address.state, + address.zip, + ]); + + return addressLines; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/is-processable-response.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/is-processable-response.ts new file mode 100644 index 00000000..6cdd38f5 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/is-processable-response.ts @@ -0,0 +1,128 @@ +import type { IValidateAddressResponse } from '../types/ivalidate-address-response'; +import type { ProcessableResponse } from '../types/processable-response'; + +export function isProcessableResponse( + response: IValidateAddressResponse, +): response is ProcessableResponse { + if ( + !('result' in response) || + !response.result || + typeof response.result !== 'object' + ) { + return false; + } + + const { result } = response; + + if ( + !('verdict' in result) || + !result.verdict || + typeof result.verdict !== 'object' + ) { + return false; + } + + if ( + !('address' in result) || + !result.address || + typeof result.address !== 'object' + ) { + return false; + } + + const { address } = result; + + if ( + !('postalAddress' in address) || + !address.postalAddress || + typeof address.postalAddress !== 'object' + ) { + return false; + } + + const { postalAddress } = address; + + if ( + !('postalCode' in postalAddress) || + typeof postalAddress.postalCode !== 'string' + ) { + return false; + } + + if ( + !('administrativeArea' in postalAddress) || + typeof postalAddress.administrativeArea !== 'string' + ) { + return false; + } + + if ( + !('locality' in postalAddress) || + typeof postalAddress.locality !== 'string' + ) { + return false; + } + + if ( + !('addressLines' in postalAddress) || + !postalAddress.addressLines || + !Array.isArray(postalAddress.addressLines) || + !postalAddress.addressLines.every(line => typeof line === 'string') + ) { + return false; + } + + if ( + !('addressComponents' in address) || + !address.addressComponents || + !Array.isArray(address.addressComponents) || + !address.addressComponents.every(component => { + if ( + !('componentName' in component) || + !component.componentName || + typeof component.componentName !== 'object' + ) { + return false; + } + + const { componentName } = component; + + if ( + !('text' in componentName) || + typeof componentName.text !== 'string' + ) { + return false; + } + + if ( + !('componentType' in component) || + typeof component.componentType !== 'string' + ) { + return false; + } + + if ( + !('confirmationLevel' in component) || + typeof component.confirmationLevel !== 'string' + ) { + return false; + } + + return true; + }) + ) { + return false; + } + + if ( + 'missingComponentTypes' in address && + (!Array.isArray(address.missingComponentTypes) || + address.missingComponentTypes.some( + componentType => typeof componentType !== 'string', + )) + ) { + return false; + } + + return true; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-missing-subpremise-error.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-missing-subpremise-error.ts new file mode 100644 index 00000000..d4b57ec9 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-missing-subpremise-error.ts @@ -0,0 +1,9 @@ +import type { ProcessableResponse } from '../types/processable-response'; + +export function shouldCreateMissingSubpremiseError( + response: ProcessableResponse, +): boolean { + return !!response.result.address.missingComponentTypes?.includes( + 'subpremise', + ); +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-review-recommended-address-error.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-review-recommended-address-error.ts new file mode 100644 index 00000000..0d537434 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-review-recommended-address-error.ts @@ -0,0 +1,18 @@ +import type { Address } from '@/model/types/addresses/address'; +import type { ProcessableResponse } from '../types/processable-response'; + +export function shouldCreateReviewRecommendedAddressError( + address: Address, + response: ProcessableResponse, +): boolean { + return ( + address.streetLine1 !== + response.result.address.postalAddress.addressLines[0] || + (address.streetLine2 ?? '') !== + (response.result.address.postalAddress.addressLines[1] ?? '') || + address.city !== response.result.address.postalAddress.locality || + address.state !== + response.result.address.postalAddress.administrativeArea || + address.zip !== response.result.address.postalAddress.postalCode.slice(0, 5) + ); +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-unconfirmed-components-error.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-unconfirmed-components-error.ts new file mode 100644 index 00000000..6d12d7a9 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/should-create-unconfirmed-components-error.ts @@ -0,0 +1,7 @@ +import type { ProcessableResponse } from '../types/processable-response'; + +export function shouldCreateUnconfirmedComponentsError( + response: ProcessableResponse, +): boolean { + return !!response.result.verdict.hasUnconfirmedComponents; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/validate-address-with-google-maps.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/validate-address-with-google-maps.ts new file mode 100644 index 00000000..fbf459b9 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/utils/validate-address-with-google-maps.ts @@ -0,0 +1,60 @@ +import 'server-only'; +import { PRIVATE_ENVIRONMENT_VARIABLES } from '@/constants/private-environment-variables'; +import { getAddressLines } from './get-address-lines'; +import { isProcessableResponse } from './is-processable-response'; +import { ServerError } from '@/errors/server-error'; +import { shouldCreateUnconfirmedComponentsError } from './should-create-unconfirmed-components-error'; +import { createUnconfirmedComponentsError } from './create-unconfirmed-components-error'; +import { shouldCreateReviewRecommendedAddressError } from './should-create-review-recommended-address-error'; +import { createReviewRecommendedAddressError } from './create-review-recommended-address-error'; +import { shouldCreateMissingSubpremiseError } from './should-create-missing-subpremise-error'; +import { createMissingSubpremiseError } from './create-missing-subpremise-error'; +import type { Address } from '@/model/types/addresses/address'; +import type { AddressFormNames } from '@/model/types/addresses/address-form-names'; + +export async function validateAddressWithGoogleMaps( + address: Address, + form: AddressFormNames, +) { + const endpoint = `https://addressvalidation.googleapis.com/v1:validateAddress?key=${PRIVATE_ENVIRONMENT_VARIABLES.GOOGLE_MAPS_API_KEY}`; + const addressLines = getAddressLines(address); + const request = { + address: { + regionCode: 'US', + addressLines, + }, + }; + + const response = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new ServerError(`Failed to validate address.`, response.status); + } + + const responseBody = await response.json(); + + if (!isProcessableResponse(responseBody)) { + throw new ServerError('Unprocessable response.', 400); + } + + if (shouldCreateUnconfirmedComponentsError(responseBody)) { + return [createUnconfirmedComponentsError(address, responseBody, form)]; + } + + const errors = []; + + if (shouldCreateReviewRecommendedAddressError(address, responseBody)) { + errors.push( + createReviewRecommendedAddressError(address, responseBody, form), + ); + } + + if (shouldCreateMissingSubpremiseError(responseBody)) { + errors.push(createMissingSubpremiseError(form)); + } + + return errors; +} diff --git a/src/services/server/validate-addresses/validate-addresses-with-google-maps/validate-addresses-with-google-maps.ts b/src/services/server/validate-addresses/validate-addresses-with-google-maps/validate-addresses-with-google-maps.ts new file mode 100644 index 00000000..bcca589b --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses-with-google-maps/validate-addresses-with-google-maps.ts @@ -0,0 +1,25 @@ +import 'server-only'; +import { bind } from 'undecorated-di'; +import { validateAddressWithGoogleMaps } from './utils/validate-address-with-google-maps'; +import type { ValidateAddressesParams } from '../validate-addresses'; +import type { AddressErrors } from '@/model/types/addresses/address-errors'; + +export const validateAddressesWithGoogleMaps = bind( + async function validateAddressesWithGoogleMaps( + params: ValidateAddressesParams, + ): Promise { + const errors = await Promise.all([ + validateAddressWithGoogleMaps(params.homeAddress, 'homeAddress'), + params.mailingAddress && + validateAddressWithGoogleMaps(params.mailingAddress, 'mailingAddress'), + params.previousAddress && + validateAddressWithGoogleMaps( + params.previousAddress, + 'previousAddress', + ), + ]); + + return errors.flat().filter(error => !!error); + }, + [], +); diff --git a/src/services/server/validate-addresses/validate-addresses.ts b/src/services/server/validate-addresses/validate-addresses.ts new file mode 100644 index 00000000..f8e2e4a8 --- /dev/null +++ b/src/services/server/validate-addresses/validate-addresses.ts @@ -0,0 +1,12 @@ +import type { Address } from '@/model/types/addresses/address'; +import type { AddressErrors } from '@/model/types/addresses/address-errors'; + +export interface ValidateAddressesParams { + homeAddress: Address; + mailingAddress?: Address; + previousAddress?: Address; +} + +export interface ValidateAddresses { + (params: ValidateAddressesParams): Promise; +} diff --git a/src/stories/components/form-components/checkbox.stories.tsx b/src/stories/components/form-components/checkbox.stories.tsx new file mode 100644 index 00000000..6ee4a969 --- /dev/null +++ b/src/stories/components/form-components/checkbox.stories.tsx @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { Checkbox } from '@/components/form-components/checkbox'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; + +const meta: Meta = { + component: Checkbox, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [checked, setChecked] = useState(false); + + return ( + + setChecked(!checked)} + /> + + ); + }, +}; diff --git a/src/stories/components/form-components/phone-input-group.stories.tsx b/src/stories/components/form-components/phone-input-group.stories.tsx new file mode 100644 index 00000000..3b85b926 --- /dev/null +++ b/src/stories/components/form-components/phone-input-group.stories.tsx @@ -0,0 +1,37 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { PhoneInputGroup } from '@/components/form-components/phone-input-group'; +import { Field } from 'fully-formed'; +import { PhoneValidator } from '@/app/register/utils/phone-validator'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; + +const meta: Meta = { + component: PhoneInputGroup, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => { + return ( + + + + ); + }, +}; diff --git a/src/stories/components/form-components/select.stories.tsx b/src/stories/components/form-components/select.stories.tsx index 5a777825..621f03a4 100644 --- a/src/stories/components/form-components/select.stories.tsx +++ b/src/stories/components/form-components/select.stories.tsx @@ -2,7 +2,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { Select } from '@/components/form-components/select'; import { Field, StringValidators, Validator } from 'fully-formed'; import { GlobalStylesProvider } from '@/stories/global-styles-provider'; -import { US_STATES_AND_TERRITORIES } from '@/constants/us-states-and-territories'; +import { US_STATE_ABBREVIATIONS } from '@/constants/us-state-abbreviations'; const meta: Meta = { component: Select, @@ -12,6 +12,16 @@ const meta: Meta = { expanded: false, }, }, + decorators: [ + Story => { + document.body.setAttribute( + 'style', + 'min-height: 100vh; max-height: 100vh; overflow: hidden; position: absolute; top: 0; left: 0;', + ); + + return ; + }, + ], }; export default meta; @@ -62,7 +72,7 @@ export const ManyOptions: Story = { { + return { + text: abbr, + value: abbr, + }; + })} + style={{ marginTop: '70vh' }} + /> + + ); + }, +}; diff --git a/src/stories/components/progress/badges/badges.stories.tsx b/src/stories/components/progress/badges/badges.stories.tsx index 018f0974..4f7944ab 100644 --- a/src/stories/components/progress/badges/badges.stories.tsx +++ b/src/stories/components/progress/badges/badges.stories.tsx @@ -4,7 +4,7 @@ import { NumberBadge } from '@/components/progress/badges/number-badge'; import { ActionBadge } from '@/components/progress/badges/action-badge'; import { PlayerBadge } from '@/components/progress/badges/player-badge'; import { Badges } from '@/components/progress/badges'; -import type { Badge } from '@/model/types/badge'; +import type { Badge } from '@/model/types/badges/badge'; import { Actions } from '@/model/enums/actions'; const meta: Meta = { diff --git a/src/stories/components/register/address-confirmation-modal.stories.tsx b/src/stories/components/register/address-confirmation-modal.stories.tsx new file mode 100644 index 00000000..e10078be --- /dev/null +++ b/src/stories/components/register/address-confirmation-modal.stories.tsx @@ -0,0 +1,165 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { useLayoutEffect } from 'react'; +import { useForm, Field } from 'fully-formed'; +import { AddressConfirmationModal } from '@/app/register/addresses/address-confirmation-modal/address-confirmation-modal'; +import { AddressesForm } from '@/app/register/addresses/addresses-form'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; +import { AddressErrorTypes } from '@/model/types/addresses/address-error-types'; + +const meta: Meta = { + component: AddressConfirmationModal, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const AllErrorTypes: Story = { + render: () => { + const form = useForm( + new AddressesForm(new Field({ name: 'zip', defaultValue: '94043' })), + ); + + useLayoutEffect(() => { + form.fields.homeAddress.fields.streetLine1.setValue('123 Mapel Lane'); + form.fields.homeAddress.fields.city.setValue('Springfield'); + form.fields.homeAddress.fields.zip.setValue('62701'); + form.fields.homeAddress.fields.state.setValue('IL'); + + form.fields.mailingAddress.setExclude(false); + form.fields.mailingAddress.fields.streetLine1.setValue('789 Elm St'); + form.fields.mailingAddress.fields.city.setValue('Brookside'); + form.fields.mailingAddress.fields.zip.setValue('10001'); + form.fields.mailingAddress.fields.state.setValue('NY'); + + form.fields.previousAddress.setExclude(false); + form.fields.previousAddress.fields.streetLine1.setValue( + '456 Oakwood Ave', + ); + form.fields.previousAddress.fields.city.setValue('Riverton'); + form.fields.previousAddress.fields.zip.setValue('90210'); + form.fields.previousAddress.fields.state.setValue('CA'); + }, [form]); + + return ( + + {}} + /> + + ); + }, +}; + +export const FailedToValidate: Story = { + render: () => { + const form = useForm( + new AddressesForm(new Field({ name: 'zip', defaultValue: '94043' })), + ); + + useLayoutEffect(() => { + form.fields.homeAddress.fields.streetLine1.setValue('123 Mapel Lane'); + form.fields.homeAddress.fields.city.setValue('Springfield'); + form.fields.homeAddress.fields.zip.setValue('62701'); + form.fields.homeAddress.fields.state.setValue('IL'); + + form.fields.mailingAddress.setExclude(false); + form.fields.mailingAddress.fields.streetLine1.setValue('789 Elm St'); + form.fields.mailingAddress.fields.city.setValue('Brookside'); + form.fields.mailingAddress.fields.zip.setValue('10001'); + form.fields.mailingAddress.fields.state.setValue('NY'); + + form.fields.previousAddress.setExclude(false); + form.fields.previousAddress.fields.streetLine1.setValue( + '456 Oakwood Ave', + ); + form.fields.previousAddress.fields.city.setValue('Riverton'); + form.fields.previousAddress.fields.zip.setValue('90210'); + form.fields.previousAddress.fields.state.setValue('CA'); + }, [form]); + + return ( + + {}} + /> + + ); + }, +}; diff --git a/src/stories/components/register/could-not-confirm.stories.tsx b/src/stories/components/register/could-not-confirm.stories.tsx new file mode 100644 index 00000000..68959609 --- /dev/null +++ b/src/stories/components/register/could-not-confirm.stories.tsx @@ -0,0 +1,52 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { UnconfirmedComponents } from '@/app/register/addresses/address-confirmation-modal/unconfirmed-components/unconfirmed-components'; +import { Modal } from '@/components/utils/modal'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; + +const meta: Meta = { + component: UnconfirmedComponents, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => { + return ( + + {}} + > + {}} + nextOrContinue={() => {}} + /> + + + ); + }, +}; diff --git a/src/stories/components/register/formatted-address.stories.tsx b/src/stories/components/register/formatted-address.stories.tsx new file mode 100644 index 00000000..32db856a --- /dev/null +++ b/src/stories/components/register/formatted-address.stories.tsx @@ -0,0 +1,149 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { FormattedAddress } from '@/app/register/addresses/address-confirmation-modal/formatted-address'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; + +const meta: Meta = { + component: FormattedAddress, +}; + +export default meta; + +type Story = StoryObj; + +export const NoEmphasizedItems: Story = { + render: () => { + return ( + + + + ); + }, +}; + +export const NoEmphasizedItemsWithApt: Story = { + render: () => { + return ( + + + + ); + }, +}; + +export const AllEmphasizedItems: Story = { + render: () => { + return ( + + + + ); + }, +}; + +export const SomeEmphasizedItems: Story = { + render: () => { + return ( + + + + ); + }, +}; + +export const LongAddress: Story = { + render: () => { + return ( + + + + ); + }, +}; diff --git a/src/stories/components/register/missing-unit.stories.tsx b/src/stories/components/register/missing-unit.stories.tsx new file mode 100644 index 00000000..5930c446 --- /dev/null +++ b/src/stories/components/register/missing-unit.stories.tsx @@ -0,0 +1,65 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { Field, FormFactory, FormTemplate, useForm } from 'fully-formed'; +import { Modal } from '@/components/utils/modal'; +import { MissingSubpremise } from '@/app/register/addresses/address-confirmation-modal/missing-subpremise'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; +import type { AddressForm } from '@/app/register/addresses/address-confirmation-modal/types/address-form'; + +const meta: Meta = { + component: MissingSubpremise, +}; + +export default meta; + +type Story = StoryObj; + +const MockAddressForm = FormFactory.createForm( + class MockAddressFormTemplate extends FormTemplate { + public readonly fields = [ + new Field({ + name: 'streetLine1', + defaultValue: '1600 Ampitheatre Parkway', + }), + new Field({ + name: 'streetLine2', + defaultValue: '', + }), + new Field({ + name: 'city', + defaultValue: 'Mountain View', + }), + new Field({ + name: 'state', + defaultValue: 'CA', + }), + new Field({ + name: 'zip', + defaultValue: '94043', + }), + ] as const; + }, +); + +export const Default: Story = { + render: () => { + const form = useForm(new MockAddressForm() as unknown as AddressForm); + + return ( + + {}} + > + {}} + /> + + + ); + }, +}; diff --git a/src/stories/components/register/review-addresses.stories.tsx b/src/stories/components/register/review-addresses.stories.tsx new file mode 100644 index 00000000..f40be422 --- /dev/null +++ b/src/stories/components/register/review-addresses.stories.tsx @@ -0,0 +1,148 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { Modal } from '@/components/utils/modal'; +import { ReviewAddresses } from '@/app/register/addresses/address-confirmation-modal/review-addresses'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; + +const meta: Meta = { + component: ReviewAddresses, +}; + +export default meta; + +type Story = StoryObj; + +export const AllAddresses: Story = { + render: () => { + return ( + + {}} + theme="light" + > + {}} + continueToNextPage={() => {}} + /> + + + ); + }, +}; + +export const HomeAddressOnly: Story = { + render: () => { + return ( + + {}} + theme="light" + > + {}} + continueToNextPage={() => {}} + /> + + + ); + }, +}; + +export const HomeAndMailing: Story = { + render: () => { + return ( + + {}} + theme="light" + > + {}} + continueToNextPage={() => {}} + /> + + + ); + }, +}; + +export const HomeAndPrevious: Story = { + render: () => { + return ( + + {}} + theme="light" + > + {}} + continueToNextPage={() => {}} + /> + + + ); + }, +}; diff --git a/src/stories/components/register/review-recommended-address.stories.tsx b/src/stories/components/register/review-recommended-address.stories.tsx new file mode 100644 index 00000000..18dd3f5d --- /dev/null +++ b/src/stories/components/register/review-recommended-address.stories.tsx @@ -0,0 +1,101 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { Field, FormFactory, FormTemplate, useForm } from 'fully-formed'; +import { Modal } from '@/components/utils/modal'; +import { ReviewRecommendedAddress } from '@/app/register/addresses/address-confirmation-modal/review-recommended-address'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; +import type { AddressForm } from '@/app/register/addresses/address-confirmation-modal/types/address-form'; + +const meta: Meta = { + component: ReviewRecommendedAddress, +}; + +export default meta; + +type Story = StoryObj; + +const MockAddressForm = FormFactory.createForm( + class MockAddressFormTemplate extends FormTemplate { + public readonly fields = [ + new Field({ + name: 'streetLine1', + defaultValue: '1600 Ampitheatre Parkway', + }), + new Field({ + name: 'streetLine2', + defaultValue: '', + }), + new Field({ + name: 'city', + defaultValue: 'Montan View', + }), + new Field({ + name: 'state', + defaultValue: 'CA', + }), + new Field({ + name: 'zip', + defaultValue: '94043', + }), + ] as const; + }, +); + +export const Default: Story = { + render: () => { + const form = useForm(new MockAddressForm() as unknown as AddressForm); + + return ( + + {}} + > + {}} + /> + + + ); + }, +}; diff --git a/src/styles/defaults/_fieldset.scss b/src/styles/defaults/_fieldset.scss new file mode 100644 index 00000000..1fa3eae3 --- /dev/null +++ b/src/styles/defaults/_fieldset.scss @@ -0,0 +1,15 @@ +legend { + padding: 0; + display: table; +} + +fieldset { + border: 0; + padding: 0.01em 0 0 0; + margin: 0; + min-width: 0; +} + +body:not(:-moz-handler-blocked) fieldset { + display: table-cell; +} diff --git a/src/styles/defaults/_index.scss b/src/styles/defaults/_index.scss index 6bc9139f..f00ba2f3 100644 --- a/src/styles/defaults/_index.scss +++ b/src/styles/defaults/_index.scss @@ -1,4 +1,5 @@ @use 'all'; @use 'body'; @use 'buttons'; -@use 'typography'; \ No newline at end of file +@use 'fieldset'; +@use 'typography'; diff --git a/src/styles/partials/_index.scss b/src/styles/partials/_index.scss index c5a8c5ad..76bac241 100644 --- a/src/styles/partials/_index.scss +++ b/src/styles/partials/_index.scss @@ -14,6 +14,7 @@ @forward 'classes/utils/color-utils'; @forward 'classes/utils/display-utils'; +@forward 'classes/utils/margins'; @forward 'classes/utils/no-drag'; @forward 'classes/utils/no-select'; diff --git a/src/styles/partials/classes/utils/_margins.scss b/src/styles/partials/classes/utils/_margins.scss new file mode 100644 index 00000000..bd7e3ee7 --- /dev/null +++ b/src/styles/partials/classes/utils/_margins.scss @@ -0,0 +1,22 @@ +@use '../../variables/spacings' as *; + +$spacings: ( + 'sm': $sm, + 'md': $md, + 'lg': $lg, +); +$margin-properties: ( + 'm': 'margin', + 'mt': 'margin-top', + 'mb': 'margin-bottom', + 'ml': 'margin-left', + 'mr': 'margin-right', +); + +@each $prop-abbreviation, $property in $margin-properties { + @each $size-abbreviation, $pixels in $spacings { + .#{$prop-abbreviation}_#{$size-abbreviation} { + #{$property}: $pixels; + } + } +} diff --git a/src/styles/partials/variables/_colors.scss b/src/styles/partials/variables/_colors.scss index 70be7f6a..c077377b 100644 --- a/src/styles/partials/variables/_colors.scss +++ b/src/styles/partials/variables/_colors.scss @@ -9,3 +9,5 @@ $lightgrey: #dedede; $white: #fff; $error: #eb0000; $success: #00852c; +$review-dark: #665415; +$review-light: #aa8c22; diff --git a/src/utils/environment/read-private-environment-variables.ts b/src/utils/environment/read-private-environment-variables.ts index 46a1da97..c8366a02 100644 --- a/src/utils/environment/read-private-environment-variables.ts +++ b/src/utils/environment/read-private-environment-variables.ts @@ -19,6 +19,12 @@ export function readPrivateEnvironmentVariables() { 'Could not load environment variable SUPABASE_SERVICE_ROLE_KEY', }) .parse(process.env.SUPABASE_SERVICE_ROLE_KEY), + GOOGLE_MAPS_API_KEY: z + .string({ + required_error: + 'Could not load environment variable GOOGLE_MAPS_API_KEY', + }) + .parse(process.env.GOOGLE_MAPS_API_KEY), VOTER_REGISTRATION_REPO_ENCRYPTION_KEY: z .string({ required_error: diff --git a/src/utils/shared/collapse-whitespace.ts b/src/utils/shared/collapse-whitespace.ts new file mode 100644 index 00000000..472e2f8b --- /dev/null +++ b/src/utils/shared/collapse-whitespace.ts @@ -0,0 +1,3 @@ +export function collapseWhitespace(str: string) { + return str.trim().replace(/(\s+)/g, ' '); +} diff --git a/src/utils/shared/replace-string-segment.ts b/src/utils/shared/replace-string-segment.ts new file mode 100644 index 00000000..51fc7776 --- /dev/null +++ b/src/utils/shared/replace-string-segment.ts @@ -0,0 +1,8 @@ +export function replaceStringSegment( + str: string, + replacementStr: string, + start: number, + end: number, +) { + return str.slice(0, start) + replacementStr + str.slice(end); +} diff --git a/src/utils/test/get-valid-google-maps-address-validation-response.ts b/src/utils/test/get-valid-google-maps-address-validation-response.ts new file mode 100644 index 00000000..6f304f34 --- /dev/null +++ b/src/utils/test/get-valid-google-maps-address-validation-response.ts @@ -0,0 +1,77 @@ +import type { Address } from '@/model/types/addresses/address'; +import type { ProcessableResponse } from '@/services/server/validate-addresses/validate-addresses-with-google-maps/types/processable-response'; + +export function getValidGoogleMapsAddressValidationResponse(address: Address) { + const processableResponseBody: ProcessableResponse = { + result: { + verdict: { + hasUnconfirmedComponents: false, + }, + address: { + postalAddress: { + postalCode: address.zip, + administrativeArea: address.state, + locality: address.city, + addressLines: [address.streetLine1], + }, + addressComponents: [ + { + componentName: { + text: address.streetLine1.slice( + 0, + address.streetLine1.indexOf(' '), + ), + }, + componentType: 'street_number', + confirmationLevel: 'CONFIRMED', + }, + { + componentName: { + text: address.streetLine1.slice( + address.streetLine1.indexOf(' ') + 1, + ), + }, + componentType: 'route', + confirmationLevel: 'CONFIRMED', + }, + { + componentName: { + text: address.city, + }, + componentType: 'locality', + confirmationLevel: 'CONFIRMED', + }, + { + componentName: { + text: address.state, + }, + componentType: 'administrative_area_level_1', + confirmationLevel: 'CONFIRMED', + }, + { + componentName: { + text: address.zip, + }, + componentType: 'postal_code', + confirmationLevel: 'CONFIRMED', + }, + ], + }, + }, + }; + + if (address.streetLine2) { + processableResponseBody.result.address.postalAddress.addressLines.push( + address.streetLine2, + ); + processableResponseBody.result.address.addressComponents.push({ + componentName: { + text: address.streetLine2, + }, + componentType: 'subpremise', + confirmationLevel: 'CONFIRMED', + }); + } + + return processableResponseBody as ProcessableResponse; +} diff --git a/src/utils/test/mock-dialog-methods.ts b/src/utils/test/mock-dialog-methods.ts index 969fb3e3..9e3f6525 100644 --- a/src/utils/test/mock-dialog-methods.ts +++ b/src/utils/test/mock-dialog-methods.ts @@ -2,6 +2,15 @@ * Mocks HTMLDialogElement methods as they are not supported by our test framework at this time. */ export function mockDialogMethods() { - HTMLDialogElement.prototype.showModal = jest.fn(); - HTMLDialogElement.prototype.close = jest.fn(); + HTMLDialogElement.prototype.showModal = jest.fn(function mock( + this: HTMLDialogElement, + ) { + this.open = true; + }); + + HTMLDialogElement.prototype.close = jest.fn(function mock( + this: HTMLDialogElement, + ) { + this.open = false; + }); } diff --git a/src/utils/test/mock-scroll-methods.ts b/src/utils/test/mock-scroll-methods.ts index 2c6f7c3e..e412e08c 100644 --- a/src/utils/test/mock-scroll-methods.ts +++ b/src/utils/test/mock-scroll-methods.ts @@ -3,6 +3,9 @@ * jest-environment-jsdom. */ export function mockScrollMethods() { + window.scroll = jest.fn(); + window.scrollBy = jest.fn(); + window.scrollTo = jest.fn(); HTMLElement.prototype.scroll = jest.fn(); HTMLElement.prototype.scrollTo = jest.fn(); HTMLElement.prototype.scrollBy = jest.fn(); diff --git a/src/utils/test/supabase-user-record-builder.ts b/src/utils/test/supabase-user-record-builder.ts index ff4c5f9d..06df8625 100644 --- a/src/utils/test/supabase-user-record-builder.ts +++ b/src/utils/test/supabase-user-record-builder.ts @@ -10,7 +10,7 @@ import { PRIVATE_ENVIRONMENT_VARIABLES } from '@/constants/private-environment-v import { UserRecordParser } from '@/services/server/user-record-parser/user-record-parser'; import type { User } from '@/model/types/user'; import type { Avatar } from '@/model/types/avatar'; -import type { Badge } from '@/model/types/badge'; +import type { Badge } from '@/model/types/badges/badge'; interface UserRecord { email: string; diff --git a/tsconfig.json b/tsconfig.json index 808f270d..3b1d094c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,9 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "src/__tests__/unit/middleware/.test.ts" + "src/__tests__/unit/middleware/.test.ts", + "src/app/register/constants/generate-state-requirements.js", + "src/model/enums/address-error-type.ts" ], "exclude": ["node_modules"] }