feat: implement custom 404 error page and internationalized navigation suggestions for backoffice and portal apps

This commit is contained in:
estifanos
2026-09-08 12:29:50 +00:00
parent a76ad9479f
commit 2d92d00e0b
9 changed files with 741 additions and 7 deletions

View File

@@ -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";

View File

@@ -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 (
<svg className="ema-nf__compass" viewBox="0 0 200 200" aria-hidden="true">
<defs>
<linearGradient id="ema-nf-rose" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--mantine-primary-color-filled)" />
<stop offset="100%" stopColor="var(--mantine-color-teal-6)" />
</linearGradient>
</defs>
{/* Dial */}
<circle
cx="100"
cy="100"
r="92"
fill="var(--mantine-color-body)"
stroke="var(--mantine-color-default-border)"
strokeWidth="2"
/>
<circle
cx="100"
cy="100"
r="92"
fill="color-mix(in srgb, var(--mantine-primary-color-filled) 5%, transparent)"
/>
<circle
cx="100"
cy="100"
r="92"
fill="none"
stroke="url(#ema-nf-rose)"
strokeWidth="3"
opacity="0.55"
/>
{/* Bezel: ticks turn as one group, slowly, like a gimbal settling. */}
<g className="ema-nf__bezel">
{TICKS.map((angle) => {
const cardinal = angle % 90 === 0;
return (
<line
key={angle}
x1="100"
y1={cardinal ? 14 : 18}
x2="100"
y2="26"
stroke="var(--mantine-primary-color-filled)"
strokeWidth={cardinal ? 3 : 1.5}
strokeLinecap="round"
opacity={cardinal ? 0.75 : 0.35}
transform={`rotate(${angle} 100 100)`}
/>
);
})}
<circle
cx="100"
cy="100"
r="72"
fill="none"
stroke="var(--mantine-primary-color-filled)"
strokeWidth="1.5"
strokeDasharray="1 10"
strokeLinecap="round"
opacity="0.5"
/>
</g>
{/* Rose: the four points, north picked out against the rest. */}
<g opacity="0.9">
<path d="M100 30 L112 94 L100 100 L88 94 Z" fill="url(#ema-nf-rose)" />
<path
d="M100 170 L112 106 L100 100 L88 106 Z"
fill="var(--mantine-primary-color-filled)"
opacity="0.4"
/>
<path
d="M170 100 L106 112 L100 100 L106 88 Z"
fill="var(--mantine-primary-color-filled)"
opacity="0.6"
/>
<path
d="M30 100 L94 112 L100 100 L94 88 Z"
fill="var(--mantine-primary-color-filled)"
opacity="0.6"
/>
</g>
{/* Needle */}
<g className="ema-nf__needle">
<path d="M100 44 L107 100 L100 108 L93 100 Z" fill="var(--mantine-color-red-6)" />
<path
d="M100 156 L107 100 L100 92 L93 100 Z"
fill="var(--mantine-color-gray-5)"
/>
</g>
<circle
cx="100"
cy="100"
r="7"
fill="var(--mantine-color-body)"
stroke="var(--mantine-primary-color-filled)"
strokeWidth="2.5"
/>
</svg>
);
}
/**
* 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 `<div>`. 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 (
<Box className="ema-nf" mih={fullHeight ? '100dvh' : 480}>
<div className="ema-nf__backdrop" aria-hidden="true">
<div className="ema-nf__grid" />
<div className="ema-nf__glow ema-nf__glow--primary" />
<div className="ema-nf__glow ema-nf__glow--accent" />
<div className="ema-nf__sea">
{/* The viewBox shows one wavelength; the path carries two, and the
band slides across the difference. */}
<svg viewBox={`0 0 ${WAVELENGTH} 140`} preserveAspectRatio="none">
<path
className="ema-nf__swell ema-nf__swell--back"
d={WAVE}
fill="var(--mantine-primary-color-filled)"
opacity="0.1"
transform="translate(0 20)"
/>
<path
className="ema-nf__swell"
d={WAVE}
fill="var(--mantine-primary-color-filled)"
opacity="0.16"
/>
</svg>
</div>
</div>
<Center mih="inherit" pos="relative" style={{ zIndex: 1 }} py={64} px="md">
<Container size="sm" p={0} w="100%">
<Stack align="center" gap="lg" className="ema-nf__enter">
{/* Decorative: the heading below carries the meaning for anyone
who is not looking at it. */}
<Group gap={0} className="ema-nf__digits" aria-hidden="true">
<span className="ema-nf__digit">4</span>
<LostCompass />
<span className="ema-nf__digit">4</span>
</Group>
<Stack align="center" gap="xs">
<Text
fz="xs"
fw={700}
c="dimmed"
tt="uppercase"
style={{ letterSpacing: '0.14em' }}
>
{t('notFound.kicker', 'Error 404 — page not found')}
</Text>
<Title order={1} ta="center" fz={{ base: 26, sm: 34 }}>
{t('notFound.title', "You've drifted off the chart")}
</Title>
<Text c="dimmed" ta="center" maw={520}>
{t(
'notFound.description',
'This page does not exist, has moved, or is not available to your account. The link may be out of date.',
)}
</Text>
<Code
mt={4}
style={{
maxWidth: '100%',
overflowWrap: 'anywhere',
}}
>
{pathname}
</Code>
</Stack>
<Group justify="center" gap="sm">
<Button
component={Link}
to={homePath}
size="md"
leftSection={<IconLifebuoy size={18} />}
>
{homeLabel ?? t('notFound.home', 'Back to dashboard')}
</Button>
<Button
size="md"
variant="default"
leftSection={<IconArrowLeft size={18} />}
onClick={() => navigate(-1)}
>
{t('notFound.back', 'Go back')}
</Button>
</Group>
{links && links.length > 0 && (
<Stack gap="sm" w="100%" mt={4}>
<Text fz="sm" fw={600} c="dimmed" ta="center">
{t('notFound.suggestions', 'Or head somewhere useful')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{links.map((link) => {
const LinkIcon = link.icon;
return (
<Paper
key={link.to}
component={Link}
to={link.to}
withBorder
p="md"
radius="md"
className="ema-nf__link"
style={{ textDecoration: 'none' }}
>
<Group gap="sm" wrap="nowrap">
{LinkIcon && (
<ThemeIcon size={36} radius="md" variant="light">
<LinkIcon size={20} stroke={1.6} />
</ThemeIcon>
)}
<div style={{ minWidth: 0, flex: 1 }}>
<Text fz="sm" fw={600} c="var(--mantine-color-text)">
{link.label}
</Text>
{link.description && (
<Text fz="xs" c="dimmed" lineClamp={1}>
{link.description}
</Text>
)}
</div>
<IconChevronRight
size={16}
stroke={2}
color="var(--mantine-color-dimmed)"
/>
</Group>
</Paper>
);
})}
</SimpleGrid>
</Stack>
)}
{footer}
</Stack>
</Container>
</Center>
</Box>
);
}

View File

@@ -0,0 +1,235 @@
/* Page-not-found surface — scoped under .ema-nf so nothing leaks into either
app. Colours resolve to Mantine variables (and the semantic tokens layered
over them), so the whole scene follows the colour scheme for free. */
.ema-nf {
position: relative;
overflow: hidden;
isolation: isolate;
background: var(--ema-surface-page, var(--mantine-color-body));
}
/* --- Ambient backdrop -----------------------------------------------------
A nautical chart under the content: a faint grid, two soft light wells, and
a horizon of drifting swell along the bottom edge. All decorative, all
behind the content, none of it hit-testable. */
.ema-nf__backdrop {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.ema-nf__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, currentColor 1px, transparent 1px),
linear-gradient(to bottom, currentColor 1px, transparent 1px);
background-size: 56px 56px;
color: var(--mantine-color-default-border);
opacity: 0.5;
/* Fades the grid out toward the edges so it reads as texture, not a table. */
mask-image: radial-gradient(ellipse 90% 70% at 50% 42%, #000 15%, transparent 78%);
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 42%, #000 15%, transparent 78%);
}
.ema-nf__glow {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.5;
}
.ema-nf__glow--primary {
inset-block-start: -14%;
inset-inline-start: 50%;
width: min(720px, 90vw);
aspect-ratio: 1;
translate: -50% 0;
background: radial-gradient(
circle,
color-mix(in srgb, var(--mantine-primary-color-filled) 22%, transparent) 0%,
transparent 68%
);
}
.ema-nf__glow--accent {
inset-block-end: -20%;
inset-inline-end: -8%;
width: min(520px, 70vw);
aspect-ratio: 1;
background: radial-gradient(
circle,
color-mix(in srgb, var(--mantine-color-teal-6) 18%, transparent) 0%,
transparent 70%
);
opacity: 0.6;
}
[data-mantine-color-scheme='dark'] .ema-nf__glow {
opacity: 0.4;
}
/* --- Swell ---------------------------------------------------------------- */
.ema-nf__sea {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
height: clamp(96px, 16vh, 168px);
width: 100%;
/* Dissolves into the page instead of meeting it at a hard waterline. */
mask-image: linear-gradient(to bottom, transparent 0%, #000 70%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 70%);
}
.ema-nf__sea svg {
display: block;
width: 100%;
height: 100%;
}
/* Each band is one path two wavelengths wide; sliding it by exactly one
wavelength (1440 user units) and looping is seamless. */
.ema-nf__swell {
animation: ema-nf-drift 18s linear infinite;
}
.ema-nf__swell--back {
animation-duration: 27s;
animation-direction: reverse;
}
@keyframes ema-nf-drift {
from {
transform: translateX(0);
}
to {
transform: translateX(-1440px);
}
}
/* --- Compass -------------------------------------------------------------- */
.ema-nf__compass {
width: clamp(104px, 17vw, 168px);
aspect-ratio: 1;
flex: none;
}
.ema-nf__bezel {
transform-box: fill-box;
transform-origin: center;
animation: ema-nf-bezel 40s linear infinite;
}
/* A compass that has lost its bearing: it sweeps, overshoots, and settles —
never resolving. The metaphor is the whole point of the illustration. */
.ema-nf__needle {
transform-box: fill-box;
transform-origin: center;
animation: ema-nf-search 11s cubic-bezier(0.5, 0, 0.3, 1) infinite;
}
@keyframes ema-nf-bezel {
to {
transform: rotate(360deg);
}
}
@keyframes ema-nf-search {
0% {
transform: rotate(-24deg);
}
22% {
transform: rotate(142deg);
}
34% {
transform: rotate(118deg);
}
58% {
transform: rotate(292deg);
}
70% {
transform: rotate(268deg);
}
100% {
transform: rotate(336deg);
}
}
/* --- Numerals ------------------------------------------------------------- */
.ema-nf__digits {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(4px, 1.4vw, 16px);
line-height: 0.82;
}
.ema-nf__digit {
font-size: clamp(88px, 17vw, 168px);
font-weight: 800;
letter-spacing: -0.05em;
/* Painted colour is the fallback; the clip only applies where supported. */
color: var(--mantine-primary-color-filled);
background-image: linear-gradient(
160deg,
var(--mantine-primary-color-filled) 0%,
var(--mantine-primary-color-filled) 38%,
var(--mantine-color-teal-6) 125%
);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* --- Entrance & quick links ----------------------------------------------- */
.ema-nf__enter {
animation: ema-nf-rise 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes ema-nf-rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.ema-nf__link {
transition:
transform 180ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1),
border-color 180ms ease;
}
.ema-nf__link:hover {
transform: translateY(-3px);
box-shadow: var(--mantine-shadow-md);
border-color: var(--mantine-primary-color-light-color);
}
/* Motion here is atmosphere, never information — dropping all of it costs the
page nothing. */
@media (prefers-reduced-motion: reduce) {
.ema-nf__swell,
.ema-nf__bezel,
.ema-nf__needle,
.ema-nf__enter,
.ema-nf__link {
animation: none !important;
transition: none !important;
}
.ema-nf__link:hover {
transform: none;
}
}