From 2d92d00e0b92e48e0b9c2665bda5539d0a66324d Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 8 Sep 2026 12:29:50 +0000 Subject: [PATCH] feat: implement custom 404 error page and internationalized navigation suggestions for backoffice and portal apps --- apps/backoffice/src/app/i18n/locales/am.ts | 13 + apps/backoffice/src/app/i18n/locales/en.ts | 15 + apps/backoffice/src/app/router/index.tsx | 49 ++- apps/portal/src/app/i18n/locales/am.ts | 14 + apps/portal/src/app/i18n/locales/en.ts | 16 + apps/portal/src/app/router.tsx | 60 +++- libs/ui/src/index.ts | 1 + libs/ui/src/lib/feedback/NotFoundPage.tsx | 345 +++++++++++++++++++++ libs/ui/src/lib/feedback/not-found.css | 235 ++++++++++++++ 9 files changed, 741 insertions(+), 7 deletions(-) create mode 100644 libs/ui/src/lib/feedback/NotFoundPage.tsx create mode 100644 libs/ui/src/lib/feedback/not-found.css diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 2815a5723..25652bbac 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -15,6 +15,19 @@ export const am: Translations = { skipToContent: "ወደ ዋናው ይዘት ዝለል", }, + notFound: { + kicker: 'ስህተት 404 — ገጹ አልተገኘም', + title: 'ከካርታው ውጭ ወጥተዋል', + description: + 'ይህ ገጽ የለም፣ ተዛውሯል፣ ወይም ለመለያዎ አይገኝም። አገናኙም ጊዜው ያለፈበት ሊሆን ይችላል።', + home: 'ወደ ዳሽቦርድ ተመለስ', + back: 'ተመለስ', + suggestions: 'ወይም ወደ ሌላ ጠቃሚ ቦታ ይሂዱ', + dashboardDesc: 'ወረፋዎችዎ እና የዛሬው ሥራዎ', + analyticsDesc: 'መጠኖች፣ የማጠናቀቂያ ጊዜ እና አዝማሚያዎች', + profileDesc: 'መለያዎ እና ምርጫዎችዎ', + }, + msg: { genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።", serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 927a73b03..27366991b 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -15,6 +15,21 @@ export const en = { skipToContent: 'Skip to main content', }, + // The page a wrong URL lands on. Labels for its suggested destinations come + // from `nav`, so they cannot drift from the sidebar. + notFound: { + kicker: 'Error 404 — page not found', + title: "You've drifted off the chart", + description: + 'This page does not exist, has moved, or is not available to your account. The link may be out of date.', + home: 'Back to dashboard', + back: 'Go back', + suggestions: 'Or head somewhere useful', + dashboardDesc: "Your queues and today's workload", + analyticsDesc: 'Volumes, turnaround and trends', + profileDesc: 'Your account and preferences', + }, + msg: { genericError: 'Something went wrong. Please try again.', serverError: 'Server error. Please try again later.', diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 0cc4aa8a8..2fe4f9a9f 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { createBrowserRouter, RouterProvider, @@ -12,7 +13,12 @@ import { RequirePermission, LICENSE_PERMISSIONS as P, } from '@ema-platform/auth'; -import { ThemeGallery } from '@ema-platform/ui'; +import { NotFoundPage, ThemeGallery } from '@ema-platform/ui'; +import { + IconChartBar, + IconLayoutDashboard, + IconUserCircle, +} from '@tabler/icons-react'; import { AuthLayout } from '../layouts/AuthLayout'; import { BackofficeLayout } from '../layouts/BackofficeLayout'; import { ProtectedRoute } from './ProtectedRoute'; @@ -67,6 +73,41 @@ const guard = (anyOf: string[], element: ReactNode) => ( {element} ); +/** + * Only the destinations every signed-in officer can reach — suggesting a + * permission-gated queue would swap one dead end for another. Labels come from + * `nav`, the same keys the sidebar uses, so the two cannot drift apart. + */ +function NotFound() { + const { t } = useTranslation(); + + return ( + + ); +} + const router = createBrowserRouter([ { element: , @@ -163,8 +204,10 @@ const router = createBrowserRouter([ }, ], }, - { path: '/404', element:
Page not found
}, - { path: '*', element: }, + // Standalone by design — a wrong URL should read as a wrong URL, not bounce + // an officer through the layout of a page they never asked for. + { path: '/404', element: }, + { path: '*', element: }, ]); export function AppRouter() { diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 3cb4288cf..31016b290 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -14,6 +14,20 @@ export const am: Translations = { skipToContent: 'ወደ ዋናው ይዘት ዝለል', }, + notFound: { + kicker: 'ስህተት 404 — ገጹ አልተገኘም', + title: 'ከካርታው ውጭ ወጥተዋል', + description: + 'ይህ ገጽ የለም፣ ተዛውሯል፣ ወይም ለመለያዎ አይገኝም። አገናኙም ጊዜው ያለፈበት ሊሆን ይችላል።', + home: 'ወደ ዳሽቦርድ ተመለስ', + back: 'ተመለስ', + suggestions: 'ወይም ወደ ሌላ ጠቃሚ ቦታ ይሂዱ', + dashboardDesc: 'ማመልከቻዎችዎ፣ ፈቃዶችዎ እና ቀጣይ እርምጃዎች', + documentsDesc: 'የጫኗቸው ሁሉም ሰነዶች', + notificationsDesc: 'ስላቀረቧቸው ጉዳዮች ማሻሻያዎች', + supportDesc: 'ለእገዛ ባለሥልጣኑን ያግኙ', + }, + msg: { genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።', serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index e42942f78..865e5dd16 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -14,6 +14,22 @@ export const en = { skipToContent: 'Skip to main content', }, + // The page a wrong URL lands on. Labels for its suggested destinations come + // from `nav`, so they cannot drift from the sidebar. + notFound: { + kicker: 'Error 404 — page not found', + title: "You've drifted off the chart", + description: + 'This page does not exist, has moved, or is not available to your account. The link may be out of date.', + home: 'Back to dashboard', + back: 'Go back', + suggestions: 'Or head somewhere useful', + dashboardDesc: 'Your applications, licences and next steps', + documentsDesc: 'Everything you have uploaded', + notificationsDesc: 'Updates on what you have filed', + supportDesc: 'Reach the authority for help', + }, + msg: { genericError: 'Something went wrong. Please try again.', serverError: 'Server error. Please try again later.', diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 3fadb35db..8833fe3d0 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -1,5 +1,12 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; -import { ThemeGallery } from "@ema-platform/ui"; +import { useTranslation } from "react-i18next"; +import { NotFoundPage, ThemeGallery } from "@ema-platform/ui"; +import { + IconBell, + IconFolder, + IconLayoutDashboard, + IconLifebuoy, +} from "@tabler/icons-react"; import { PortalLayout } from "./layouts/PortalLayout"; import { ProtectedRoute } from "./components/ProtectedRoute"; import { LandingRoute } from "./components/LandingRoute"; @@ -57,6 +64,47 @@ import { WaiverPage } from "./features/waiver/pages/WaiverPage"; import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage"; +/** + * Only routes with no permission gate, so a suggestion can never land the + * applicant on a second refusal. Labels come from `nav`, the same keys the + * sidebar uses, so the two cannot drift apart. + */ +function NotFound() { + const { t } = useTranslation(); + + return ( + + ); +} + export const router = createBrowserRouter([ // Public landing page — institutional overview + role-based entry points. { path: "/", element: }, @@ -483,7 +531,11 @@ export const router = createBrowserRouter([ element: , }, - // A typo'd URL should not maroon a signed-in user on the marketing page — - // ProtectedRoute sends anonymous visitors on to / (landing) instead. - { path: "*", element: }, + // A typo'd URL used to be redirected silently to the dashboard, which read as + // the app ignoring the link rather than as a missing page. Say so instead, + // and offer the way back — the primary action is /dashboard, so a signed-in + // user is one click from where the redirect used to dump them, and + // ProtectedRoute still sends anonymous visitors on to / (landing). + { path: "/404", element: }, + { path: "*", element: }, ]); diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 598162325..3b374bc9b 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -9,6 +9,7 @@ export * from "./lib/feedback/notify"; export * from "./lib/feedback/FeatureUnavailable"; export * from "./lib/feedback/EmptyState"; export * from "./lib/feedback/ErrorState"; +export * from "./lib/feedback/NotFoundPage"; export * from "./lib/feedback/PageLoader"; export * from "./lib/feedback/StatusBadge"; export * from "./lib/components/MaritimeLoader"; diff --git a/libs/ui/src/lib/feedback/NotFoundPage.tsx b/libs/ui/src/lib/feedback/NotFoundPage.tsx new file mode 100644 index 000000000..60b84b125 --- /dev/null +++ b/libs/ui/src/lib/feedback/NotFoundPage.tsx @@ -0,0 +1,345 @@ +import type { ReactNode } from 'react'; +import { + Box, + Button, + Center, + Code, + Container, + Group, + Paper, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconArrowLeft, + IconChevronRight, + IconLifebuoy, + type Icon, +} from '@tabler/icons-react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import './not-found.css'; + +export interface NotFoundLink { + label: string; + /** One line on why someone would go here instead. */ + description?: string; + to: string; + icon?: Icon; +} + +export interface NotFoundPageProps { + /** Where the primary action goes. Each app passes its own signed-in home. */ + homePath?: string; + homeLabel?: string; + /** + * A handful of real destinations. A dead end that only says "go home" makes + * the visitor guess; naming the pages they probably wanted does not. + */ + links?: NotFoundLink[]; + /** Anything extra below the actions — a support link, a contact line. */ + footer?: ReactNode; + /** + * Fills the viewport. Turn this off when the page renders inside an app + * layout, where the shell already owns the height. + */ + fullHeight?: boolean; +} + +/** 16 bezel ticks; every fourth is a cardinal point and drawn longer. */ +const TICKS = Array.from({ length: 16 }, (_, i) => i * 22.5); + +const WAVELENGTH = 1440; + +/** + * Two wavelengths of swell as one continuous path. + * + * One path rather than two abutting copies: the band slides by exactly one + * wavelength and loops, so it needs twice the width it shows, and two filled + * shapes meeting at a shared edge leave an anti-aliased hairline down the + * screen. The curve is symmetric about its midpoint, so the repeat has no kink. + */ +const WAVE = (() => { + const cycle = (x: number) => + ` C${x + 120},48 ${x + 240},48 ${x + 360},72` + + ` C${x + 480},96 ${x + 600},96 ${x + 720},72` + + ` C${x + 840},48 ${x + 960},48 ${x + 1080},72` + + ` C${x + 1200},96 ${x + 1320},96 ${x + 1440},72`; + + return `M0,72${cycle(0)}${cycle(WAVELENGTH)} L${WAVELENGTH * 2},140 L0,140 Z`; +})(); + +/** The compass that cannot find the page — the illustration, not information. */ +function LostCompass() { + return ( + + ); +} + +/** + * The page a wrong URL lands on. + * + * Both apps used to swallow a bad path — the portal redirected it to the + * dashboard, the backoffice rendered a bare `
`. Neither told anyone what + * had happened, so a typo'd or stale link looked like the app misbehaving. + * This says plainly what was asked for, that it does not exist, and where to + * go instead. + */ +export function NotFoundPage({ + homePath = '/', + homeLabel, + links, + footer, + fullHeight = true, +}: NotFoundPageProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + return ( + +