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 (
+
+
+
+
+
+
+
+ {/* The viewBox shows one wavelength; the path carries two, and the
+ band slides across the difference. */}
+
+
+
+
+
+
+
+ {/* Decorative: the heading below carries the meaning for anyone
+ who is not looking at it. */}
+
+ 4
+
+ 4
+
+
+
+
+ {t('notFound.kicker', 'Error 404 — page not found')}
+
+
+ {t('notFound.title', "You've drifted off the chart")}
+
+
+ {t(
+ 'notFound.description',
+ 'This page does not exist, has moved, or is not available to your account. The link may be out of date.',
+ )}
+
+
+ {pathname}
+
+
+
+
+ }
+ >
+ {homeLabel ?? t('notFound.home', 'Back to dashboard')}
+
+ }
+ onClick={() => navigate(-1)}
+ >
+ {t('notFound.back', 'Go back')}
+
+
+
+ {links && links.length > 0 && (
+
+
+ {t('notFound.suggestions', 'Or head somewhere useful')}
+
+
+ {links.map((link) => {
+ const LinkIcon = link.icon;
+ return (
+
+
+ {LinkIcon && (
+
+
+
+ )}
+