From b0bd2703d21d5a706c32ff91e593e426424ab0d8 Mon Sep 17 00:00:00 2001 From: Dario Au Date: Wed, 5 Mar 2025 10:40:48 -0700 Subject: [PATCH] feat(frontend): sin confirmation screen (#326) --- .../app/.server/locales/protected-en.json | 31 +++ .../app/.server/locales/protected-fr.json | 31 +++ frontend/app/components/page-details.tsx | 2 +- frontend/app/components/page-title.tsx | 5 +- frontend/app/i18n-routes.ts | 8 + frontend/app/routes/protected/layout.tsx | 2 +- .../multi-channel/sin-confirmation.tsx | 223 ++++++++++++++++++ frontend/app/utils/date-utils.ts | 30 ++- .../__snapshots__/page-details.test.tsx.snap | 2 +- 9 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 frontend/app/routes/protected/multi-channel/sin-confirmation.tsx diff --git a/frontend/app/.server/locales/protected-en.json b/frontend/app/.server/locales/protected-en.json index 03e25401..2e901f05 100644 --- a/frontend/app/.server/locales/protected-en.json +++ b/frontend/app/.server/locales/protected-en.json @@ -16,6 +16,9 @@ "title": "In-person SIN request", "description": "Process an application or amendment" }, + "first-time": { + "title": "First-time SIN request" + }, "enquiry-only": { "title": "Enquiry only", "description": "Search for a SIN record" @@ -381,5 +384,33 @@ "next": "Next", "previous": "Previous", "matches": "Matches" + }, + "sin-confirmation": { + "page-title": "SIN confirmation", + "print": "Print", + "finish": "Finish", + "protected-b": "PROTECTED B", + "social-insurance-number": "Social Insurance Number (SIN)", + "names-on-record": "Names on the SIN record", + "first-name": "First Name", + "middle-name": "Middle Name(s)", + "family-name": "Family Name(s)", + "address": "Address", + "protect-sin": { + "title": "Protect your SIN; it is confidential", + "description": "Keep any document containing your SIN in a safe place." + }, + "use-of-sin": { + "title": "Use of your SIN", + "description": "You are required to provide your SIN to your employer within three days after the day you receive it. Also, some programs and/or services authenticate a person's identity using data on the SIN record; ensure you are using the names as shown above." + }, + "sin-begin-9": { + "title": "If your SIN begins with the number 9", + "description": "You must present a valid proof of authorization to work in Canada to your employer. Your SIN record must be updated to reflect the most recent expiry date." + }, + "more-information": { + "title": "For more information, visit our Web site:", + "description": "Canada.ca/social-insurance-number" + } } } diff --git a/frontend/app/.server/locales/protected-fr.json b/frontend/app/.server/locales/protected-fr.json index ff139e5b..1fcf788a 100644 --- a/frontend/app/.server/locales/protected-fr.json +++ b/frontend/app/.server/locales/protected-fr.json @@ -16,6 +16,9 @@ "title": "Demande NAS en personne", "description": "Traiter une demande ou une modification" }, + "first-time": { + "title": "Demande NAS pour la première fois" + }, "enquiry-only": { "title": "Demande uniquement", "description": "Rechercher un enregistrement SIN" @@ -382,5 +385,33 @@ "next": "Next", "previous": "Previous", "matches": "Matches" + }, + "sin-confirmation": { + "page-title": "Confirmation du NAS", + "print": "Imprimer", + "finish": "Terminer", + "protected-b": "PROTÉGÉ B", + "social-insurance-number": "Numéro d'assurance sociale (NAS)", + "names-on-record": "Noms au dossier de NAS", + "first-name": "Prénom", + "middle-name": "Second(s) prénom(s)", + "family-name": "Nom(s) de famile", + "address": "Adresse", + "protect-sin": { + "title": "Protégez votre NAS, il est confidentiel", + "description": "Conservez tout document où i'on retrouve votre NAS dans un enroit sûr." + }, + "use-of-sin": { + "title": "Utilisation de votre NAS", + "description": "Vous devez fournir votre NAS à votre employeur dans les trois jours suivant sa recéption. Aussi, centains programmes et/ou services utilisent les données au dossier de NAS afin d'authentifier i'dentité d'une personne. Assurez-vous d'utiliser les noms qui figurent ci-dessus." + }, + "sin-begin-9": { + "title": "Si votre NAS débute par le chiffre 9", + "description": "Vous devez présenter à votre employeur une autorisation valide vous permettant de travailler au Canada. Votre dossier de NAS doit être mis à jour afin de refléter la plus récente date d'expiration." + }, + "more-information": { + "title": "Pour plus de renseignements, consultez notre site Web\u00a0:", + "description": "Canada.ca/numero-assurance-sociale" + } } } diff --git a/frontend/app/components/page-details.tsx b/frontend/app/components/page-details.tsx index c074dea5..685d2a45 100644 --- a/frontend/app/components/page-details.tsx +++ b/frontend/app/components/page-details.tsx @@ -12,7 +12,7 @@ export function PageDetails({ buildDate, buildVersion, pageId, ...props }: PageD const { t } = useTranslation(['gcweb']); return ( -
+

{t('gcweb:page-details.page-details')}

diff --git a/frontend/app/components/page-title.tsx b/frontend/app/components/page-title.tsx index bf0bf08f..9a7b267f 100644 --- a/frontend/app/components/page-title.tsx +++ b/frontend/app/components/page-title.tsx @@ -4,12 +4,13 @@ import { cn } from '~/utils/tailwind-utils'; export type PageTitleProps = Omit, 'id' | 'property'> & { subTitle?: string; + subTitleClassName?: string; }; -export function PageTitle({ children, className, subTitle, ...props }: PageTitleProps) { +export function PageTitle({ children, className, subTitle, subTitleClassName, ...props }: PageTitleProps) { return (
- {subTitle &&

{subTitle}

} + {subTitle &&

{subTitle}

}

{t('protected:index.public')} -
+
diff --git a/frontend/app/routes/protected/multi-channel/sin-confirmation.tsx b/frontend/app/routes/protected/multi-channel/sin-confirmation.tsx new file mode 100644 index 00000000..985ded77 --- /dev/null +++ b/frontend/app/routes/protected/multi-channel/sin-confirmation.tsx @@ -0,0 +1,223 @@ +import type { ReactNode } from 'react'; +import { useId, useRef } from 'react'; + +import type { RouteHandle } from 'react-router'; +import { useFetcher } from 'react-router'; + +import type { ResourceKey } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +import type { Info, Route } from './+types/sin-confirmation'; + +import { serverEnvironment } from '~/.server/environment'; +import { requireAuth } from '~/.server/utils/auth-utils'; +import { i18nRedirect } from '~/.server/utils/route-utils'; +import { Button } from '~/components/button'; +import { PageTitle } from '~/components/page-title'; +import { AppError } from '~/errors/app-error'; +import { ErrorCodes } from '~/errors/error-codes'; +import { getTranslation } from '~/i18n-config.server'; +import { handle as parentHandle } from '~/routes/protected/layout'; +import { dateToLocalizedText } from '~/utils/date-utils'; + +export const handle = { + i18nNamespace: [...parentHandle.i18nNamespace, 'protected'], +} as const satisfies RouteHandle; + +export async function loader({ context, request }: Route.LoaderArgs) { + requireAuth(context.session, new URL(request.url), ['user']); + const { t } = await getTranslation(request, handle.i18nNamespace); + + //TODO: replace with record data (names, city, etc) + return { + documentTitle: t('protected:sin-confirmation.page-title'), + recordDetails: { + date: dateToLocalizedText(serverEnvironment.BASE_TIMEZONE), + sinNumber: '123456789', + firstName: 'Johnathan', + middleNames: ['Joe', 'James'], + familyNames: ['Doe', 'Smith'], + address: '123 Main St. Suite 4B', + postalCode: 'A1A 1A1', + city: 'City', + province: 'Province', + }, + }; +} + +export function meta({ data }: Route.MetaArgs) { + return [{ title: data.documentTitle }]; +} + +export async function action({ context, request }: Route.ActionArgs) { + requireAuth(context.session, new URL(request.url), ['user']); + + const formData = await request.formData(); + const action = formData.get('action'); + + switch (action) { + case 'finish': { + throw i18nRedirect('routes/protected/request.tsx', request); //TODO: update redirect to proper page + } + default: { + throw new AppError(`Unrecognized action: ${action}`, ErrorCodes.UNRECOGNIZED_ACTION); + } + } +} + +export default function SinConfirmation({ loaderData, actionData, params }: Route.ComponentProps) { + const { t } = useTranslation(handle.i18nNamespace); + const fetcherKey = useId(); + const fetcher = useFetcher({ key: fetcherKey }); + const isSubmitting = fetcher.state !== 'idle'; + const contentRef = useRef(null); + const recordDetails = loaderData.recordDetails; + const { dateEn, dateFr } = recordDetails.date; + + return ( + <> + + {t('protected:sin-confirmation.page-title')} + +
+
+
+
Date:
+
+ {dateEn} + / + {dateFr} +
+
+

+ +

+
+
+

+ : +

+

+ {recordDetails.sinNumber.slice(0, 3)} + - + {recordDetails.sinNumber.slice(3, 6)} + - + {recordDetails.sinNumber.slice(6, 9)} +

+
+
+

+ +

+
+ + {recordDetails.firstName} + + + {recordDetails.middleNames.map((name) => ( + + {name} + + ))} + + + {recordDetails.familyNames.map((name) => ( + + {name} + + ))} + + + {recordDetails.address} + + {recordDetails.city && {recordDetails.city}} + {recordDetails.province && {recordDetails.province}} + {recordDetails.postalCode} + + +
+
+
+ + + + +
+
+ + + + + + ); +} + +interface BilingualTextProps { + resourceKey: ResourceKey; +} + +function BilingualText({ resourceKey }: BilingualTextProps) { + const { t } = useTranslation(handle.i18nNamespace); + return ( + <> + {t(resourceKey, { lng: 'en' })} + / + {t(resourceKey, { lng: 'fr' })} + + ); +} + +interface ConfirmationDetailProps { + resourceKey: ResourceKey; + children: ReactNode; +} + +function ConfirmationDetail({ resourceKey, children }: ConfirmationDetailProps) { + return ( +
+
+ : +
+
+ {children} +
+
+ ); +} + +interface BilingualTextColumnsProps { + titleKey: ResourceKey; + descriptionKey: ResourceKey; +} + +function BilingualTextColumns({ titleKey, descriptionKey }: BilingualTextColumnsProps) { + const { t } = useTranslation(handle.i18nNamespace); + return ( +
+
+
{t(titleKey, { lng: 'en' })}
+
{t(descriptionKey, { lng: 'en' })}
+
+
+
{t(titleKey, { lng: 'fr' })}
+
{t(descriptionKey, { lng: 'fr' })}
+
+
+ ); +} diff --git a/frontend/app/utils/date-utils.ts b/frontend/app/utils/date-utils.ts index 471d803a..cc19edf1 100644 --- a/frontend/app/utils/date-utils.ts +++ b/frontend/app/utils/date-utils.ts @@ -73,7 +73,35 @@ export function isValidTimeZone(timeZone: string): boolean { } /** -<<<<<<< HEAD + * @returns The local IANA time zone name. + */ +export function getLocalTimeZone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +/** + * @param timezone - The IANA time zone name (e.g., 'America/New_York', 'Europe/London'). + * @param date - Optional date or timestamp to use. If not provided, the current date and time are used. + * Can be a number (milliseconds since epoch), a string (parsable by `new Date()`), or a Date object. + * @returns The date formatted in both English ("MMM dd, yyyy") and French ("dd MMM. yyyy"). + */ +export function dateToLocalizedText(timezone: string, date?: number | string | Date): { dateEn: string; dateFr: string } { + const targetDate = getStartOfDayInTimezone(timezone, date); + return { + dateEn: targetDate.toLocaleDateString('en-CA', { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + dateFr: targetDate.toLocaleDateString('fr-CA', { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + }; +} + +/** * Checks if a given string is a valid date string in ISO 8601 format (YYYY-MM-DD). * * This function uses `parseISO` (presumably from a date/time library like date-fns) diff --git a/frontend/tests/components/__snapshots__/page-details.test.tsx.snap b/frontend/tests/components/__snapshots__/page-details.test.tsx.snap index eb87154d..6f058fbf 100644 --- a/frontend/tests/components/__snapshots__/page-details.test.tsx.snap +++ b/frontend/tests/components/__snapshots__/page-details.test.tsx.snap @@ -3,7 +3,7 @@ exports[`PageDetails > should render the pageid, app version, and date modified > expected html 1`] = `