Skip to content

Commit

Permalink
feat(frontend): sin confirmation screen (#326)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dario-Au authored Mar 5, 2025
1 parent 26017f4 commit b0bd270
Show file tree
Hide file tree
Showing 9 changed files with 328 additions and 6 deletions.
31 changes: 31 additions & 0 deletions frontend/app/.server/locales/protected-en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
}
}
31 changes: 31 additions & 0 deletions frontend/app/.server/locales/protected-fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
}
}
2 changes: 1 addition & 1 deletion frontend/app/components/page-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function PageDetails({ buildDate, buildVersion, pageId, ...props }: PageD
const { t } = useTranslation(['gcweb']);

return (
<section className="mt-16 mb-8" {...props}>
<section className="mt-16 mb-8 print:hidden" {...props}>
<h2 className="sr-only">{t('gcweb:page-details.page-details')}</h2>
<dl id="wb-dtmd" className="space-y-1">
<div className="flex gap-2">
Expand Down
5 changes: 3 additions & 2 deletions frontend/app/components/page-title.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { cn } from '~/utils/tailwind-utils';

export type PageTitleProps = Omit<ComponentProps<'h1'>, 'id' | 'property'> & {
subTitle?: string;
subTitleClassName?: string;
};

export function PageTitle({ children, className, subTitle, ...props }: PageTitleProps) {
export function PageTitle({ children, className, subTitle, subTitleClassName, ...props }: PageTitleProps) {
return (
<div className="mt-10 mb-8">
{subTitle && <h2>{subTitle}</h2>}
{subTitle && <h2 className={subTitleClassName}>{subTitle}</h2>}
<h1
id="wb-cont"
tabIndex={-1}
Expand Down
8 changes: 8 additions & 0 deletions frontend/app/i18n-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ export const i18nRoutes = [
},
],
},
{
id: 'PROT-0016',
file: 'routes/protected/multi-channel/sin-confirmation.tsx',
paths: {
en: '/en/protected/multi-channel/sin-confirmation',
fr: '/fr/protege/multi-canal/confirmation-de-nas',
},
},
//
// XState-driven in-person flow (poc)
//
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/routes/protected/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
<MenuItem file="routes/public/index.tsx">{t('protected:index.public')}</MenuItem>
</AppBar>
</header>
<main className="container">
<main className="container print:w-full print:max-w-none">
<Outlet />
<PageDetails buildDate={BUILD_DATE} buildVersion={BUILD_VERSION} pageId={pageId} />
</main>
Expand Down
223 changes: 223 additions & 0 deletions frontend/app/routes/protected/multi-channel/sin-confirmation.tsx
Original file line number Diff line number Diff line change
@@ -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<Info['actionData']>({ key: fetcherKey });
const isSubmitting = fetcher.state !== 'idle';
const contentRef = useRef<HTMLDivElement>(null);
const recordDetails = loaderData.recordDetails;
const { dateEn, dateFr } = recordDetails.date;

return (
<>
<PageTitle className="print:hidden" subTitleClassName="print:hidden" subTitle={t('protected:first-time.title')}>
{t('protected:sin-confirmation.page-title')}
</PageTitle>
<div className="space-y-8 print:m-3 print:text-xs" ref={contentRef}>
<div className="grid items-center gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 print:grid-cols-3 print:sm:grid-cols-4">
<dl className="flex items-center">
<dt className="mr-[1ch]">Date:</dt>
<dd className="font-semibold">
<span lang="en">{dateEn}</span>
<span className="mx-[0.5ch]">/</span>
<span lang="fr">{dateFr}</span>
</dd>
</dl>
<p className="font-semibold">
<BilingualText resourceKey="protected:sin-confirmation.protected-b" />
</p>
</div>
<section>
<h3 className="text-center font-semibold">
<BilingualText resourceKey="protected:sin-confirmation.social-insurance-number" />:
</h3>
<p className="mt-4 text-center font-semibold">
{recordDetails.sinNumber.slice(0, 3)}
<span className="mx-[0.5ch]">-</span>
{recordDetails.sinNumber.slice(3, 6)}
<span className="mx-[0.5ch]">-</span>
{recordDetails.sinNumber.slice(6, 9)}
</p>
</section>
<section>
<h3 className="text-center font-semibold">
<BilingualText resourceKey="protected:sin-confirmation.names-on-record" />
</h3>
<dl className="mt-5 grid items-center gap-5">
<ConfirmationDetail resourceKey="protected:sin-confirmation.first-name">
{recordDetails.firstName}
</ConfirmationDetail>
<ConfirmationDetail resourceKey="protected:sin-confirmation.middle-name">
{recordDetails.middleNames.map((name) => (
<span key={name} className="block">
{name}
</span>
))}
</ConfirmationDetail>
<ConfirmationDetail resourceKey="protected:sin-confirmation.family-name">
{recordDetails.familyNames.map((name) => (
<span key={name} className="block">
{name}
</span>
))}
</ConfirmationDetail>
<ConfirmationDetail resourceKey="protected:sin-confirmation.address">
<span className="block">{recordDetails.address}</span>
<span className="block">
{recordDetails.city && <span className="mr-[0.5ch]">{recordDetails.city}</span>}
{recordDetails.province && <span className="mr-[0.5ch]">{recordDetails.province}</span>}
{recordDetails.postalCode}
</span>
</ConfirmationDetail>
</dl>
</section>
<section className="grid gap-8 sm:gap-6">
<BilingualTextColumns
titleKey="protected:sin-confirmation.protect-sin.title"
descriptionKey="protected:sin-confirmation.protect-sin.description"
/>
<BilingualTextColumns
titleKey="protected:sin-confirmation.use-of-sin.title"
descriptionKey="protected:sin-confirmation.use-of-sin.description"
/>
<BilingualTextColumns
titleKey="protected:sin-confirmation.sin-begin-9.title"
descriptionKey="protected:sin-confirmation.sin-begin-9.description"
/>
<BilingualTextColumns
titleKey="protected:sin-confirmation.more-information.title"
descriptionKey="protected:sin-confirmation.more-information.description"
/>
</section>
</div>
<fetcher.Form method="post" noValidate className="mt-12 space-x-3 print:hidden">
<Button type="button" variant="primary" onClick={() => print()}>
{t('protected:sin-confirmation.print')}
</Button>
<Button name="action" value="finish" id="finish-button" disabled={isSubmitting}>
{t('protected:sin-confirmation.finish')}
</Button>
</fetcher.Form>
</>
);
}

interface BilingualTextProps {
resourceKey: ResourceKey;
}

function BilingualText({ resourceKey }: BilingualTextProps) {
const { t } = useTranslation(handle.i18nNamespace);
return (
<>
<span lang="en">{t(resourceKey, { lng: 'en' })}</span>
<span className="mx-[0.5ch]">/</span>
<span lang="fr">{t(resourceKey, { lng: 'fr' })}</span>
</>
);
}

interface ConfirmationDetailProps {
resourceKey: ResourceKey;
children: ReactNode;
}

function ConfirmationDetail({ resourceKey, children }: ConfirmationDetailProps) {
return (
<div className="grid sm:grid-cols-2 sm:gap-[2.5ch] print:grid-cols-2 print:gap-[2.5ch]">
<dt className="mt-0 mb-auto flex items-center">
<BilingualText resourceKey={resourceKey} />:
</dt>
<dd className="font-semibold">
<span className="block">{children}</span>
</dd>
</div>
);
}

interface BilingualTextColumnsProps {
titleKey: ResourceKey;
descriptionKey: ResourceKey;
}

function BilingualTextColumns({ titleKey, descriptionKey }: BilingualTextColumnsProps) {
const { t } = useTranslation(handle.i18nNamespace);
return (
<dl className="grid gap-y-3 sm:grid-cols-2 sm:gap-[2.5ch] print:grid-cols-2 print:gap-[2.5ch]">
<div lang="en">
<dt className="font-semibold">{t(titleKey, { lng: 'en' })}</dt>
<dd>{t(descriptionKey, { lng: 'en' })}</dd>
</div>
<div lang="fr">
<dt className="font-semibold">{t(titleKey, { lng: 'fr' })}</dt>
<dd>{t(descriptionKey, { lng: 'fr' })}</dd>
</div>
</dl>
);
}
30 changes: 29 additions & 1 deletion frontend/app/utils/date-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit b0bd270

Please sign in to comment.