diff --git a/src/app/onboarding/availability/page.tsx b/src/app/onboarding/availability/page.tsx index 7865a40d..265dfd03 100644 --- a/src/app/onboarding/availability/page.tsx +++ b/src/app/onboarding/availability/page.tsx @@ -11,7 +11,7 @@ import { FormControl, FormField, FormItem, FormLabel } from '@/components/Form'; import Icon from '@/components/Icon'; import TextAreaInput from '@/components/TextAreaInput'; import TextInput from '@/components/TextInput'; -import { availabilitySchema } from '@/data/formSchemas'; +import { availabilitySchema, CHAR_LIMIT_MSG } from '@/data/formSchemas'; import { CardForm, Flex } from '@/styles/containers'; import { H1Centered } from '@/styles/text'; import { @@ -31,6 +31,8 @@ export default function Page() { const onboarding = useGuardedOnboarding(); const { backlinkHref, ebbTo, pageProgress } = useOnboardingNavigation(); const { push } = useRouter(); + const [availabilityError, setAvailabilityError] = useState(''); + const [hoursError, setHoursError] = useState(''); // scroll to top useScrollToTop(); @@ -106,7 +108,7 @@ export default function Page() { { + setAvailabilityError( + newValue.length > 400 ? CHAR_LIMIT_MSG : '', + ); onboarding.updateProfile({ availability_description: newValue, }); diff --git a/src/app/onboarding/legal-credentials/page.tsx b/src/app/onboarding/legal-credentials/page.tsx index 8f3aaaee..cdc01949 100644 --- a/src/app/onboarding/legal-credentials/page.tsx +++ b/src/app/onboarding/legal-credentials/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { FormProvider, useForm } from 'react-hook-form'; @@ -17,9 +17,10 @@ import { import Icon from '@/components/Icon'; import InputDropdown from '@/components/InputDropdown'; import RadioGroup from '@/components/RadioGroup'; +import TextAreaInput from '@/components/TextAreaInput'; import TextInput from '@/components/TextInput'; import { usStates } from '@/data/citiesAndStates'; -import { attorneyCredentialSchema } from '@/data/formSchemas'; +import { attorneyCredentialSchema, CHAR_LIMIT_MSG } from '@/data/formSchemas'; import { CardForm, Flex } from '@/styles/containers'; import { H1Centered } from '@/styles/text'; import { formatTruthy, identity } from '@/utils/helpers'; @@ -34,6 +35,7 @@ export default function Page() { const onboarding = useGuardedOnboarding(); const { backlinkHref, ebbTo, pageProgress } = useOnboardingNavigation(); const { push } = useRouter(); + const [commentError, setCommentError] = useState(''); // scroll to top useScrollToTop(); @@ -45,6 +47,9 @@ export default function Page() { stateBarred: onboarding.profile.state_barred ?? undefined, barNumber: onboarding.profile.bar_number ?? undefined, eoirRegistered: onboarding.profile.eoir_registered ?? undefined, + legalCredentialComment: + onboarding.profile.legal_credential_comment ?? undefined, + barred: onboarding.profile.has_bar_number ?? undefined, }, }); @@ -52,7 +57,8 @@ export default function Page() { const isEmpty = useMemo( () => !(formValues.stateBarred && formValues.barNumber) || - formValues.eoirRegistered === undefined, + formValues.eoirRegistered === undefined || + (!formValues.barred && !formValues.legalCredentialComment), [formValues], ); @@ -108,8 +114,8 @@ export default function Page() { /> - If you are barred in multiple states, choose your preferred - state + If you are barred in multiple states, please choose your + preferred state @@ -118,23 +124,30 @@ export default function Page() { ( - - What is your attorney bar number in this state? - + Do you have a bar number in this state? - { + const bool = newValue === 'Yes'; + const barNum = bool ? '' : 'N/A'; onboarding.updateProfile({ - bar_number: newValue, + has_bar_number: bool, + bar_number: barNum, }); - field.onChange(newValue); + form.setValue('barNumber', barNum); + field.onChange(bool); }} /> @@ -142,6 +155,34 @@ export default function Page() { )} /> + {formValues.barred && ( + ( + + + What is your attorney bar number in this state? + + + { + onboarding.updateProfile({ + bar_number: newValue, + }); + field.onChange(newValue); + }} + /> + + + )} + /> + )} + + ( + + + Is there anything about your bar status we should know? + {formValues.barred && ' (optional)'} + + + { + setCommentError( + newValue.length > 400 ? CHAR_LIMIT_MSG : '', + ); + onboarding.updateProfile({ + legal_credential_comment: newValue, + }); + field.onChange(newValue); + }} + /> + + + For example, if you were formerly barred but are not + currently; or, if your state does not have a bar number. + + + )} + /> + ebbTo(backlinkHref)}> Back diff --git a/src/app/onboarding/review/page.tsx b/src/app/onboarding/review/page.tsx index 427c194a..8b01f1a8 100644 --- a/src/app/onboarding/review/page.tsx +++ b/src/app/onboarding/review/page.tsx @@ -153,10 +153,18 @@ export default function Page() { -

What is your attorney bar number?

-

{onboarding.profile.bar_number || 'N/A'}

+

Do you have a bar number in this state?

+

{onboarding.profile.has_bar_number ? 'Yes' : 'No'}

+ {onboarding.profile.has_bar_number && ( + + +

What is your attorney bar number?

+

{onboarding.profile.bar_number || 'N/A'}

+
+
+ )}

@@ -173,6 +181,12 @@ export default function Page() {

+ + +

Is there anything about your bar status we should know?

+

{onboarding.profile.legal_credential_comment ?? 'N/A'}

+
+
)} diff --git a/src/app/onboarding/styles.ts b/src/app/onboarding/styles.ts index de8e4dde..e6c6fcd0 100644 --- a/src/app/onboarding/styles.ts +++ b/src/app/onboarding/styles.ts @@ -52,6 +52,7 @@ export const SectionField = styled.div<{ $optional?: boolean }>` flex-direction: column; gap: 16px; width: 100%; + overflow-wrap: break-word; & > h4 { ${({ $optional }) => diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx index 24567392..2f880c3b 100644 --- a/src/components/Buttons.tsx +++ b/src/components/Buttons.tsx @@ -45,7 +45,8 @@ interface ButtonProps { const ButtonStyles = css` ${sans.style} appearance: none; - color: ${({ $primaryColor }) => ($primaryColor ? 'white' : COLORS.blueMid)}; + color: ${({ $primaryColor, $secondaryColor }) => + $primaryColor ? 'white' : $secondaryColor || COLORS.blueMid}; background: ${({ $primaryColor }) => $primaryColor || 'white'}; padding: 10px 20px; border-radius: 5px; @@ -85,9 +86,9 @@ const ButtonStyles = css` } &:active { - color: ${COLORS.greyMid}; - border-color: ${COLORS.greyLight}; - background: ${COLORS.greyLight}; + color: ${COLORS.greyMid} !important; + border-color: ${COLORS.greyLight} !important; + background: ${COLORS.greyLight} !important; } ` : null}; diff --git a/src/components/NavBar/index.tsx b/src/components/NavBar/index.tsx index e23261cc..f3fb0eab 100644 --- a/src/components/NavBar/index.tsx +++ b/src/components/NavBar/index.tsx @@ -32,12 +32,12 @@ const navlinks: NavLink[] = [ export default function NavBar() { const profile = useProfile(); - if (!profile) throw new Error('Profile must be defined.'); + if (!profile) throw new Error('Profile must be defined'); const currentPath = usePathname(); const auth = useAuth(); - if (!auth) throw new Error('Auth must be defined.'); + if (!auth) throw new Error('Auth must be defined'); const authButtonView = useMemo(() => { if (profile.profileReady && auth.userId) diff --git a/src/components/RadioGroup/index.tsx b/src/components/RadioGroup/index.tsx index 72fa1ce8..d49079ed 100644 --- a/src/components/RadioGroup/index.tsx +++ b/src/components/RadioGroup/index.tsx @@ -54,13 +54,13 @@ export default function RadioGroup({ )} {options.map(o => ( - + handleChange(o)} /> diff --git a/src/components/SettingsSection/AvailabilitySection.tsx b/src/components/SettingsSection/AvailabilitySection.tsx index aa018bc8..bf07ba1e 100644 --- a/src/components/SettingsSection/AvailabilitySection.tsx +++ b/src/components/SettingsSection/AvailabilitySection.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; -import { availabilitySchema } from '@/data/formSchemas'; +import { availabilitySchema, CHAR_LIMIT_MSG } from '@/data/formSchemas'; import { Box } from '@/styles/containers'; import { Profile } from '@/types/schema'; import { @@ -37,6 +37,7 @@ export default function AvailabilitySection() { const [startDate, setStartDate] = useState( getDateDefault(profile.profileData ?? {}), ); + const [availabilityError, setAvailabilityError] = useState(''); const form = useForm>({ resolver: zodResolver(availabilitySchema), @@ -65,6 +66,7 @@ export default function AvailabilitySection() { setIsEditing(false); form.reset(getFormDefaults(profile.profileData ?? {})); setStartDate(getDateDefault(profile.profileData ?? {})); + setAvailabilityError(''); }} isSubmitting={form.formState.isSubmitting} > @@ -123,8 +125,13 @@ export default function AvailabilitySection() { { + setAvailabilityError( + newValue.length > 400 ? CHAR_LIMIT_MSG : '', + ); + field.onChange(newValue); + }} /> )} /> diff --git a/src/components/SettingsSection/RolesSection.tsx b/src/components/SettingsSection/RolesSection.tsx index 95b0ab48..39a9f916 100644 --- a/src/components/SettingsSection/RolesSection.tsx +++ b/src/components/SettingsSection/RolesSection.tsx @@ -3,7 +3,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; import { usStates } from '@/data/citiesAndStates'; -import { FormRoleEnum, roleAndLegalSchema } from '@/data/formSchemas'; +import { + CHAR_LIMIT_MSG, + FormRoleEnum, + roleAndLegalSchema, +} from '@/data/formSchemas'; import { roleOptions } from '@/data/roles'; import { Box } from '@/styles/containers'; import { H3 } from '@/styles/text'; @@ -24,6 +28,7 @@ import DateInput from '../DateInput'; import { FormMessage } from '../Form'; import InputDropdown from '../InputDropdown'; import RadioGroup from '../RadioGroup'; +import TextAreaInput from '../TextAreaInput'; import TextInput from '../TextInput'; const getFormDefaults = ( @@ -50,6 +55,8 @@ const getFormDefaults = ( : undefined, stateBarred: profile.state_barred, roles: defaultRole, + barred: profile.has_bar_number, + legalCredentialComment: profile.legal_credential_comment, }; return defaultValue; @@ -63,6 +70,7 @@ const getDateDefault = (profile: Partial): string => export default function RolesSection() { const { profile, auth } = useProfileAuth(); const [isEditing, setIsEditing] = useState(false); + const [commentError, setCommentError] = useState(''); const [expectedBarDate, setExpectedBarDate] = useState( getDateDefault(profile.profileData ?? {}), @@ -104,6 +112,10 @@ export default function RolesSection() { eoir_registered: isAttorney || isLegalFellow ? values.eoirRegistered : null, expected_bar_date: isLegalFellow ? values.expectedBarDate : null, + legal_credential_comment: isAttorney + ? values.legalCredentialComment + : null, + has_bar_number: isAttorney ? values.barred : null, }), profile.setRoles(rolesToUpdate), ]); @@ -111,7 +123,7 @@ export default function RolesSection() { setIsEditing(false); }; - const { roles } = form.watch(); + const { roles, barred } = form.watch(); const isAttorney = roles === 'ATTORNEY' || roles === 'ATTORNEY,INTERPRETER'; const isLegalFellow = roles === 'LEGAL_FELLOW' || roles === 'LEGAL_FELLOW,INTERPRETER'; @@ -169,6 +181,7 @@ export default function RolesSection() { getFormDefaults(profile.roles, profile.profileData ?? {}), ); setExpectedBarDate(getDateDefault(profile.profileData ?? {})); + setCommentError(''); }} > formatTruthy(v, 'Yes', 'No', 'N/A')} render={({ field, fieldState }) => ( - { + const bool = newValue === 'Yes'; + const barNum = bool ? '' : 'N/A'; + form.setValue('barNumber', barNum); + field.onChange(bool); + }} /> )} /> + + {barred && ( + ( + + )} + /> + )} )} @@ -300,6 +341,33 @@ export default function RolesSection() { )} /> ) : null} + + {isAttorney && ( + v ?? 'N/A'} + render={({ field, fieldState }) => ( + { + setCommentError( + newValue.length > 400 ? CHAR_LIMIT_MSG : '', + ); + field.onChange(newValue); + }} + /> + )} + /> + )} diff --git a/src/components/SettingsSection/index.tsx b/src/components/SettingsSection/index.tsx index 40f47b94..97aefb9a 100644 --- a/src/components/SettingsSection/index.tsx +++ b/src/components/SettingsSection/index.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useMemo } from 'react'; import { ControllerProps, FieldPath, FieldValues } from 'react-hook-form'; +import COLORS from '@/styles/colors'; import { Flex } from '@/styles/containers'; import { Spinner } from '@/styles/spinner'; import { H2, P } from '@/styles/text'; @@ -54,7 +55,12 @@ export function SettingSection({ {isEditing ? ( - diff --git a/src/data/formSchemas.ts b/src/data/formSchemas.ts index bccdc270..ecd255c9 100644 --- a/src/data/formSchemas.ts +++ b/src/data/formSchemas.ts @@ -1,6 +1,8 @@ import z from 'zod'; import { getCurrentDate } from '@/utils/helpers'; +export const CHAR_LIMIT_MSG = 'Your text exceeds the 400-character limit'; + const zodDropdownOption = { label: z.string(), value: z.string(), @@ -73,29 +75,44 @@ export const availabilitySchema = z.object({ required_error: 'Please include your estimated availability in hours per month', }) - .nonnegative({ message: 'This value must be nonnegative' }) - .max(744, { message: 'Please enter a valid hours per month' }), + .nonnegative({ message: 'This value must be nonnegative' }), startDate: z .date({ required_error: 'Please include your estimated starting date of availability', }) .min(getCurrentDate(), { message: 'Must select a current or future date' }), - availability: z.string().optional().nullable(), + availability: z.string().max(400, CHAR_LIMIT_MSG).optional().nullable(), }); -export const attorneyCredentialSchema = z.object({ - stateBarred: z - .string({ - required_error: 'Please include a state', - invalid_type_error: 'Please include a state', - }) - .min(1, { message: 'Please include a state' }), - barNumber: z - .string({ required_error: 'Please include your attorney bar number' }) - .min(1, { message: 'Please include your attorney bar number' }), - eoirRegistered: z.boolean({ required_error: 'Must select one option' }), -}); +export const attorneyCredentialSchema = z + .object({ + stateBarred: z + .string({ + required_error: 'Please include a state', + invalid_type_error: 'Please include a state', + }) + .min(1, { message: 'Please include a state' }), + barred: z.boolean(), + barNumber: z + .string({ required_error: 'Please include your attorney bar number' }) + .min(1, { message: 'Please include your attorney bar number' }), + eoirRegistered: z.boolean({ required_error: 'Must select one option' }), + legalCredentialComment: z + .string() + .max(400, CHAR_LIMIT_MSG) + .optional() + .nullable(), + }) + .superRefine((input, ctx) => { + if (!input.barred && !input.legalCredentialComment) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Please provide some additional information', + path: ['legalCredentialComment'], + }); + return ctx; + }); export const legalFellowCredentialSchema = z.object({ expectedBarDate: z @@ -135,6 +152,12 @@ export const roleAndLegalSchema = z }) .optional() .nullable(), + barred: z.boolean().optional().nullable(), + legalCredentialComment: z + .string() + .max(400, CHAR_LIMIT_MSG) + .optional() + .nullable(), }) .superRefine((input, ctx) => { // attorney or legal fellow must fill out EOIR registered @@ -162,6 +185,12 @@ export const roleAndLegalSchema = z message: 'Please include a state', path: ['stateBarred'], }); + if (!input.barred && !input.legalCredentialComment) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Please provide some additional information', + path: ['legalCredentialComment'], + }); } // legal fellow fields required diff --git a/src/data/languages.ts b/src/data/languages.ts index 8a09ddd2..f3f047ed 100644 --- a/src/data/languages.ts +++ b/src/data/languages.ts @@ -11,7 +11,7 @@ export const languages = iso6393 .sort((l1, l2) => l1.localeCompare(l2)); export const optionalLanguages: DropdownOption[] = [ - { label: 'N/A', value: 'N/A' }, + { label: 'Not Applicable', value: 'Not Applicable' }, ...languages.map(l => ({ label: l, value: l })), ]; diff --git a/src/styles/containers.ts b/src/styles/containers.ts index a31144fb..10f65387 100644 --- a/src/styles/containers.ts +++ b/src/styles/containers.ts @@ -95,6 +95,7 @@ export const Flex = styled.div` export const Fill = styled.div` width: 100%; + overflow-wrap: break-word; `; const CardStyles = css` diff --git a/src/types/schema.d.ts b/src/types/schema.d.ts index a1566c65..873bd76d 100644 --- a/src/types/schema.d.ts +++ b/src/types/schema.d.ts @@ -15,6 +15,8 @@ export interface Profile { city: string; phone_number: string; state_barred?: string; + legal_credential_comment?: string; + has_bar_number?: boolean; } export interface ProfileToUpload @@ -26,6 +28,8 @@ export interface ProfileToUpload | 'state_barred' | 'eoir_registered' | 'bar_number' + | 'legal_credential_comment' + | 'has_bar_number' > { start_date: Date; expected_bar_date?: Date | null; @@ -33,6 +37,8 @@ export interface ProfileToUpload state_barred?: string | null; eoir_registered?: boolean | null; bar_number?: string | null; + legal_credential_comment?: string | null; + has_bar_number?: boolean | null; } // only used for ProfileRoles diff --git a/src/utils/OnboardingProvider.tsx b/src/utils/OnboardingProvider.tsx index 980ec60d..e5d93c8c 100644 --- a/src/utils/OnboardingProvider.tsx +++ b/src/utils/OnboardingProvider.tsx @@ -113,7 +113,11 @@ export default function OnboardingProvider({ if (roles.length === 0) throw new Error('Error: could not determine role!'); if (roles.includes('ATTORNEY')) { - if (!userProfile.bar_number) throw new Error('Bar number is required!'); + if (userProfile.has_bar_number && !userProfile.bar_number) + throw new Error('Bar number is required!'); + + if (!userProfile.has_bar_number && !userProfile.legal_credential_comment) + throw new Error('Comment is required in the absence of bar number!'); if (!userProfile.state_barred) throw new Error('State barred is required!'); @@ -148,6 +152,8 @@ export default function OnboardingProvider({ eoir_registered: userProfile.eoir_registered, user_id: uid, phone_number: userProfile.phone_number, + legal_credential_comment: userProfile.legal_credential_comment, + has_bar_number: userProfile.has_bar_number, }; const userLangs = new Set(canReads.concat(canSpeaks)); diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index c229ca87..50f50667 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -2,6 +2,7 @@ import { useCallback, useContext, useEffect, useMemo } from 'react'; import { usePathname, useRouter } from 'next/navigation'; +import CONFIG from '@/lib/configs'; import { useAuth } from './AuthProvider'; import { OnboardingContext } from './OnboardingProvider'; import { useProfile } from './ProfileProvider'; @@ -48,6 +49,8 @@ export const useScrollToTop = () => { */ export const useGuardedOnboarding = () => { const onboarding = useContext(OnboardingContext); + const pathname = usePathname(); + if (!onboarding) throw new Error( 'Component should be wrapped inside the onboarding context', @@ -57,8 +60,9 @@ export const useGuardedOnboarding = () => { const { push } = useRouter(); useEffect(() => { - if (flow.length === 0) push('/onboarding/'); - }, [flow, push]); + if (flow.length === 0 && pathname !== CONFIG.onboardingHome) + push('/onboarding/'); + }, [flow, push, pathname]); return { flow, ...rest }; };