diff --git a/frontend/app/page-ids.ts b/frontend/app/page-ids.ts index c0ec19d47..b73732922 100644 --- a/frontend/app/page-ids.ts +++ b/frontend/app/page-ids.ts @@ -151,6 +151,8 @@ export const pageIds = { updateFederalProvincialTerritorialBenefits: 'CDCP-RENW-CHLD-0006', confirmPhone: 'CDCP-RENW-CHLD-0007', confirmEmail: 'CDCP-RENW-CHLD-0008', + confirmMaritalStatus: 'CDCP-RENW-CHLD-0009', + maritalStatus: 'CDCP-RENW-CHLD-00010', }, }, demographicSurvey: { diff --git a/frontend/app/routes/public/renew/$id/child/confirm-marital-status.tsx b/frontend/app/routes/public/renew/$id/child/confirm-marital-status.tsx new file mode 100644 index 000000000..af83184be --- /dev/null +++ b/frontend/app/routes/public/renew/$id/child/confirm-marital-status.tsx @@ -0,0 +1,162 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; +import { data, redirect } from '@remix-run/node'; +import { useFetcher, useLoaderData, useParams } from '@remix-run/react'; + +import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; + +import { TYPES } from '~/.server/constants'; +import { loadRenewChildState } from '~/.server/routes/helpers/renew-child-route-helpers'; +import { saveRenewState } from '~/.server/routes/helpers/renew-route-helpers'; +import { getFixedT } from '~/.server/utils/locale.utils'; +import { transformFlattenedError } from '~/.server/utils/zod.utils'; +import { Button, ButtonLink } from '~/components/buttons'; +import { CsrfTokenInput } from '~/components/csrf-token-input'; +import { useErrorSummary } from '~/components/error-summary'; +import { InputRadios } from '~/components/input-radios'; +import { LoadingButton } from '~/components/loading-button'; +import { Progress } from '~/components/progress'; +import { pageIds } from '~/page-ids'; +import { getTypedI18nNamespaces } from '~/utils/locale-utils'; +import { mergeMeta } from '~/utils/meta-utils'; +import type { RouteHandleData } from '~/utils/route-utils'; +import { getPathById } from '~/utils/route-utils'; +import { getTitleMetaTags } from '~/utils/seo-utils'; + +enum MaritalStatusRadioOptions { + No = 'no', + Yes = 'yes', +} + +export const handle = { + i18nNamespaces: getTypedI18nNamespaces('renew-child', 'renew', 'gcweb'), + pageIdentifier: pageIds.public.renew.child.confirmMaritalStatus, + pageTitleI18nKey: 'renew-child:confirm-marital-status.page-title', +} as const satisfies RouteHandleData; + +export const meta: MetaFunction = mergeMeta(({ data }) => { + return data ? getTitleMetaTags(data.meta.title) : []; +}); + +export async function loader({ context: { appContainer, session }, params, request }: LoaderFunctionArgs) { + const state = loadRenewChildState({ params, request, session }); + const t = await getFixedT(request, handle.i18nNamespaces); + + const meta = { title: t('gcweb:meta.title.template', { title: t('renew-child:confirm-marital-status.page-title') }) }; + + return { id: state.id, meta, defaultState: state.hasMaritalStatusChanged, editMode: state.editMode }; +} + +export async function action({ context: { appContainer, session }, params, request }: ActionFunctionArgs) { + const formData = await request.formData(); + + const securityHandler = appContainer.get(TYPES.routes.security.SecurityHandler); + securityHandler.validateCsrfToken({ formData, session }); + const state = loadRenewChildState({ params, request, session }); + + const t = await getFixedT(request, handle.i18nNamespaces); + + const confirmMaritalStatusSchema = z.object({ + hasMaritalStatusChanged: z.boolean({ + errorMap: () => ({ message: t('renew-child:confirm-marital-status.error-message.has-marital-status-changed-required') }), + }), + }); + + const parsedDataResult = confirmMaritalStatusSchema.safeParse({ + hasMaritalStatusChanged: formData.get('hasMaritalStatusChanged') ? formData.get('hasMaritalStatusChanged') === 'yes' : undefined, + }); + + if (!parsedDataResult.success) { + return data({ errors: transformFlattenedError(parsedDataResult.error.flatten()) }, { status: 400 }); + } + + saveRenewState({ + params, + session, + state: { + hasMaritalStatusChanged: parsedDataResult.data.hasMaritalStatusChanged, + maritalStatus: undefined, + }, + }); + + if (state.editMode) { + if (parsedDataResult.data.hasMaritalStatusChanged) { + return redirect(getPathById('public/renew/$id/child/marital-status', params)); + } + return redirect(getPathById('public/renew/$id/child/review-parent-information', params)); + } + + if (parsedDataResult.data.hasMaritalStatusChanged) { + return redirect(getPathById('public/renew/$id/child/marital-status', params)); + } + + return redirect(getPathById('public/renew/$id/child/confirm-phone', params)); +} + +export default function RenewChildConfirmMaritalStatus() { + const { t } = useTranslation(handle.i18nNamespaces); + const { defaultState, editMode } = useLoaderData(); + const params = useParams(); + const fetcher = useFetcher(); + const isSubmitting = fetcher.state !== 'idle'; + const errors = fetcher.data?.errors; + const errorSummary = useErrorSummary(errors, { + hasMaritalStatusChanged: 'input-radio-has-marital-status-changed-option-0', + }); + + return ( + <> +
+ +
+
+

{t('renew:required-label')}

+ + + +
+ +
+ {editMode ? ( +
+ + + {t('marital-status.cancel-btn')} + +
+ ) : ( +
+ + {t('renew-child:confirm-marital-status.continue-btn')} + + + {t('renew-child:confirm-marital-status.back-btn')} + +
+ )} +
+
+ + ); +} diff --git a/frontend/app/routes/public/renew/$id/child/marital-status.tsx b/frontend/app/routes/public/renew/$id/child/marital-status.tsx new file mode 100644 index 000000000..b18984d74 --- /dev/null +++ b/frontend/app/routes/public/renew/$id/child/marital-status.tsx @@ -0,0 +1,231 @@ +import type { ChangeEventHandler } from 'react'; +import { useMemo, useState } from 'react'; + +import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; +import { redirect } from '@remix-run/node'; +import { useFetcher, useLoaderData, useParams } from '@remix-run/react'; + +import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; + +import { TYPES } from '~/.server/constants'; +import { loadRenewChildState } from '~/.server/routes/helpers/renew-child-route-helpers'; +import type { PartnerInformationState } from '~/.server/routes/helpers/renew-route-helpers'; +import { renewStateHasPartner, saveRenewState } from '~/.server/routes/helpers/renew-route-helpers'; +import { getFixedT, getLocale } from '~/.server/utils/locale.utils'; +import { transformFlattenedError } from '~/.server/utils/zod.utils'; +import { Button, ButtonLink } from '~/components/buttons'; +import { CsrfTokenInput } from '~/components/csrf-token-input'; +import { useErrorSummary } from '~/components/error-summary'; +import { InputCheckbox } from '~/components/input-checkbox'; +import { InputPatternField } from '~/components/input-pattern-field'; +import type { InputRadiosProps } from '~/components/input-radios'; +import { InputRadios } from '~/components/input-radios'; +import { LoadingButton } from '~/components/loading-button'; +import { Progress } from '~/components/progress'; +import { pageIds } from '~/page-ids'; +import { useClientEnv } from '~/root'; +import { getTypedI18nNamespaces } from '~/utils/locale-utils'; +import { mergeMeta } from '~/utils/meta-utils'; +import type { RouteHandleData } from '~/utils/route-utils'; +import { getPathById } from '~/utils/route-utils'; +import { getTitleMetaTags } from '~/utils/seo-utils'; +import { formatSin, isValidSin, sinInputPatternFormat } from '~/utils/sin-utils'; + +enum FormAction { + Continue = 'continue', + Cancel = 'cancel', + Save = 'save', +} + +export const handle = { + i18nNamespaces: getTypedI18nNamespaces('renew-child', 'renew', 'gcweb'), + pageIdentifier: pageIds.public.renew.child.maritalStatus, + pageTitleI18nKey: 'renew-child:marital-status.page-title', +} as const satisfies RouteHandleData; + +export const meta: MetaFunction = mergeMeta(({ data }) => { + return data ? getTitleMetaTags(data.meta.title) : []; +}); + +export async function loader({ context: { appContainer, session }, params, request }: LoaderFunctionArgs) { + const state = loadRenewChildState({ params, request, session }); + if (!state.hasMaritalStatusChanged) { + return redirect(getPathById('public/renew/$id/child/confirm-marital-status', params)); + } + + const t = await getFixedT(request, handle.i18nNamespaces); + const locale = getLocale(request); + const maritalStatuses = appContainer.get(TYPES.domain.services.MaritalStatusService).listLocalizedMaritalStatuses(locale); + + const meta = { title: t('gcweb:meta.title.template', { title: t('renew-child:marital-status.page-title') }) }; + + return { defaultState: { maritalStatus: state.maritalStatus, ...state.partnerInformation }, editMode: state.editMode, id: state.id, maritalStatuses, meta }; +} + +export async function action({ context: { appContainer, session }, params, request }: ActionFunctionArgs) { + const formData = await request.formData(); + + const securityHandler = appContainer.get(TYPES.routes.security.SecurityHandler); + securityHandler.validateCsrfToken({ formData, session }); + + const state = loadRenewChildState({ params, request, session }); + const t = await getFixedT(request, handle.i18nNamespaces); + + // state validation schema + const maritalStatusSchema = z.object({ + maritalStatus: z + .string({ errorMap: () => ({ message: t('renew-child:marital-status.error-message.marital-status-required') }) }) + .trim() + .min(1, t('renew-child:marital-status.error-message.marital-status-required')), + }); + + const currentYear = new Date().getFullYear().toString(); + const partnerInformationSchema = z.object({ + confirm: z.boolean().refine((val) => val === true, t('renew-child:marital-status.error-message.confirm-required')), + yearOfBirth: z + .string() + .trim() + .min(1, t('renew-child:marital-status.error-message.date-of-birth-year-required')) + .refine((year) => year < currentYear, t('renew-child:marital-status.error-message.yob-is-future')), + socialInsuranceNumber: z + .string() + .trim() + .min(1, t('renew-child:marital-status.error-message.sin-required')) + .refine(isValidSin, t('renew-child:marital-status.error-message.sin-valid')) + .refine((sin) => isValidSin(sin) && formatSin(sin, '') !== state.partnerInformation?.socialInsuranceNumber, t('renew-child:marital-status.error-message.sin-unique')), + }) satisfies z.ZodType; + + const maritalStatusData = { + maritalStatus: formData.get('maritalStatus') ? String(formData.get('maritalStatus')) : undefined, + }; + const partnerInformationData = { + confirm: formData.get('confirm') === 'yes', + yearOfBirth: String(formData.get('yearOfBirth') ?? ''), + socialInsuranceNumber: String(formData.get('socialInsuranceNumber') ?? ''), + }; + + const parsedMaritalStatus = maritalStatusSchema.safeParse(maritalStatusData); + const parsedPartnerInformation = partnerInformationSchema.safeParse(partnerInformationData); + + if (!parsedMaritalStatus.success || (renewStateHasPartner(parsedMaritalStatus.data.maritalStatus) && !parsedPartnerInformation.success)) { + return { + errors: { + ...(parsedMaritalStatus.error ? transformFlattenedError(parsedMaritalStatus.error.flatten()) : {}), + ...(parsedMaritalStatus.success && renewStateHasPartner(parsedMaritalStatus.data.maritalStatus) && parsedPartnerInformation.error ? transformFlattenedError(parsedPartnerInformation.error.flatten()) : {}), + }, + }; + } + + saveRenewState({ params, session, state: { maritalStatus: parsedMaritalStatus.data.maritalStatus, partnerInformation: parsedPartnerInformation.data } }); + + if (state.editMode) { + return redirect(getPathById('public/renew/$id/child/review-parent-information', params)); + } + + return redirect(getPathById('public/renew/$id/child/confirm-phone', params)); +} + +export default function RenewChildMaritalStatus() { + const { t } = useTranslation(handle.i18nNamespaces); + const { defaultState, editMode, maritalStatuses } = useLoaderData(); + const { MARITAL_STATUS_CODE_COMMONLAW, MARITAL_STATUS_CODE_MARRIED } = useClientEnv(); + const params = useParams(); + const fetcher = useFetcher(); + const isSubmitting = fetcher.state !== 'idle'; + + const [marriedOrCommonlaw, setMarriedOrCommonlaw] = useState(defaultState.maritalStatus); + + const errors = fetcher.data?.errors; + const errorSummary = useErrorSummary(errors, { + maritalStatus: 'input-radio-marital-status-option-0', + yearOfBirth: 'year-of-birth', + socialInsuranceNumber: 'social-insurance-number', + confirm: 'input-checkbox-confirm', + }); + + const handleChange: ChangeEventHandler = (e) => { + setMarriedOrCommonlaw(e.target.value); + }; + + const maritalStatusOptions = useMemo(() => { + return maritalStatuses.map((status) => ({ defaultChecked: status.id === defaultState.maritalStatus, children: status.name, value: status.id, onChange: handleChange })); + }, [defaultState, maritalStatuses]); + + return ( + <> +
+ +
+
+

{t('renew:required-label')}

+ + + +
+ + + {(marriedOrCommonlaw === MARITAL_STATUS_CODE_COMMONLAW.toString() || marriedOrCommonlaw === MARITAL_STATUS_CODE_MARRIED.toString()) && ( + <> +

{t('renew-child:marital-status.spouse-or-commonlaw')}

+

{t('renew-child:marital-status.provide-sin')}

+

{t('renew-child:marital-status.required-information')}

+ + + + {t('renew-child:marital-status.confirm-checkbox')} + + + )} +
+ {editMode ? ( +
+ + +
+ ) : ( +
+ + {t('renew-child:marital-status.continue-btn')} + + + {t('renew-child:marital-status.back-btn')} + +
+ )} +
+
+ + ); +} diff --git a/frontend/app/routes/public/renew/$id/child/parent-intro.tsx b/frontend/app/routes/public/renew/$id/child/parent-intro.tsx index 77579b5f5..9524e6137 100644 --- a/frontend/app/routes/public/renew/$id/child/parent-intro.tsx +++ b/frontend/app/routes/public/renew/$id/child/parent-intro.tsx @@ -99,11 +99,11 @@ export default function RenewChildParentIntro() { variant="primary" loading={isSubmitting && submitAction === FormAction.Continue} endIcon={faChevronRight} - data-gc-analytics-customclick="ESDC-EDSC:CDCP Renew Child Application Form:Continue - Parent intro click" + data-gc-analytics-customclick="ESDC-EDSC:CDCP Renew Application Form-Child:Continue - Parent intro click" > {t('renew-child:parent-intro.continue-btn')} - diff --git a/frontend/app/routes/public/routes.ts b/frontend/app/routes/public/routes.ts index 223de1395..22358b241 100644 --- a/frontend/app/routes/public/routes.ts +++ b/frontend/app/routes/public/routes.ts @@ -612,6 +612,16 @@ export const routes = [ file: 'routes/public/renew/$id/child/parent-intro.tsx', paths: { en: '/:lang/renew/:id/child/parent-intro', fr: '/:lang/renew/:id/enfant/parent-intro' }, }, + { + id: 'public/renew/$id/child/confirm-marital-status', + file: 'routes/public/renew/$id/child/confirm-marital-status.tsx', + paths: { en: '/:lang/renew/:id/child/confirm-marital-status', fr: '/:lang/renew/:id/enfant/confirmer-etat-civil' }, + }, + { + id: 'public/renew/$id/child/marital-status', + file: 'routes/public/renew/$id/child/marital-status.tsx', + paths: { en: '/:lang/renew/:id/child/marital-status', fr: '/:lang/renew/:id/enfant/etat-civil' }, + }, { id: 'public/renew/$id/child/children/$childId/dental-insurance', file: 'routes/public/renew/$id/child/children/$childId/dental-insurance.tsx', diff --git a/frontend/public/locales/en/renew-child.json b/frontend/public/locales/en/renew-child.json index 6633f3278..d2faefd26 100644 --- a/frontend/public/locales/en/renew-child.json +++ b/frontend/public/locales/en/renew-child.json @@ -237,5 +237,47 @@ "confirm-email-valid": "Enter confirmation of email address in the correct format, such as name@example.com", "receive-comms-required": "Select whether you would like to receive email communication" } + }, + "confirm-marital-status": { + "page-title": "Marital status", + "has-marital-status-changed": "Has your marital status or spouse changed since you applied for the Canadian Dental Care Plan?", + "help-message": "The marital status we have on file can also be found on the renewal letter you received from Service Canada.", + "back-btn": "Back", + "continue-btn": "Continue", + "cancel-btn": "Cancel", + "save-btn": "Save", + "radio-options": { + "yes": "Yes", + "no": "No" + }, + "error-message": { + "has-marital-status-changed-required": "Please indicate if your marital status has changed" + } + }, + "marital-status": { + "page-title": "Marital status", + "marital-status": "What is your current marital status?", + "spouse-or-commonlaw": "Spouse or common-law partner information", + "provide-sin": "Provide the Social Insurance Number (SIN) of your current spouse or common-law partner. The information is required to calculate your adjusted family net income.", + "required-information": "Your spouse or common-law partner must submit their own application or renewal for the Canadian Dental Care Plan.", + "back-btn": "Back", + "continue-btn": "Continue", + "cancel-btn": "Cancel", + "save-btn": "Save", + "sin": "Social Insurance Number (SIN)", + "year-of-birth": "Year of birth", + "confirm-checkbox": "I confirm that my spouse or common-law partner is aware and has agreed to share their personal information.", + "help-message": { + "sin": "Enter the 9-digit SIN" + }, + "error-message": { + "marital-status-required": "Select marital status", + "confirm-required": "Checkbox must be selected", + "date-of-birth-year-required": "Year of birth is required", + "yob-is-future": "Year of birth must be in the past", + "sin-required": "Enter 9-digit SIN, for example 123 456 789", + "sin-valid": "Must be a valid SIN", + "sin-unique": "The Social Insurance Number (SIN) must be unique" + } } } diff --git a/frontend/public/locales/fr/renew-child.json b/frontend/public/locales/fr/renew-child.json index 2b356a5b1..4fde81421 100644 --- a/frontend/public/locales/fr/renew-child.json +++ b/frontend/public/locales/fr/renew-child.json @@ -240,5 +240,47 @@ "confirm-email-valid": "Entrez la confirmation de l'adresse courriel dans le bon format, par exemple nom@example.com", "receive-comms-required": "Select whether you would like to receive email communication" } + }, + "confirm-marital-status": { + "page-title": "État civil", + "has-marital-status-changed": "Votre état civil ou votre conjoint a-t-il changé depuis que vous avez présenté une demande au Régime canadien de soins dentaires?", + "help-message": "L'état civil que nous avons au dossier se trouve également sur la lettre de renouvellement que vous avez reçue de Service Canada.", + "back-btn": "Retour", + "continue-btn": "Continuer", + "cancel-btn": "Annuler", + "save-btn": "Sauvegarder", + "radio-options": { + "yes": "Oui", + "no": "Non" + }, + "error-message": { + "has-marital-status-changed-required": "Please indicate if your marital status has changed" + } + }, + "marital-status": { + "page-title": "État civil", + "marital-status": "Quel est votre état civil actuel?", + "spouse-or-commonlaw": "Renseignements sur l'époux/épouse ou le conjoint/la conjointe de fait", + "provide-sin": "Fournissez le numéro d'assurance sociale de votre époux/épouse ou conjoint/conjointe de fait actuel(le). Ces renseignements sont nécessaires pour calculer votre revenu familial net rajusté.", + "required-information": "Votre époux/épouse ou conjoint/conjointe de fait doit présenter sa propre demande initiale ou demande de renouvellement au Régime canadien de soins dentaires.", + "back-btn": "Retour", + "continue-btn": "Continuer", + "cancel-btn": "Cancel", + "save-btn": "Save", + "sin": "Numéro d'assurance sociale (NAS)", + "year-of-birth": "Année de naissance", + "confirm-checkbox": "Je confirme que mon époux/épouse ou conjoint/conjointe de fait est au courant de la communication de ses renseignements personnels et y a consenti.", + "help-message": { + "sin": "Entrez le NAS à 9 chiffres" + }, + "error-message": { + "marital-status-required": "Sélectionnez l'état civil", + "confirm-required": "La case à cocher doit être sélectionnée", + "date-of-birth-year-required": "L'année de naissance doit être fournie", + "yob-is-future": "L'année de naissance doit être dans le passé", + "sin-required": "Entrez un NAS de 9 chiffres", + "sin-valid": "Doit être un NAS valide", + "sin-unique": "Le numéro d'assurance sociale (NAS) doit être unique" + } } }