Merge remote-tracking branch 'origin/certficate' into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-17 11:19:17 +00:00
96 changed files with 12690 additions and 2701 deletions

View File

@@ -21,3 +21,5 @@ export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/data/useServerTable";
export * from "./lib/landing/LandingPage";
export * from "./lib/landing/landing-copy";

View File

@@ -2,20 +2,32 @@ import { Stack, Text, Group } from '@mantine/core';
import { IconCheck, IconX } from '@tabler/icons-react';
import { z } from 'zod';
export function passwordRules(minLength: number) {
const DEFAULT_LABELS = (minLength: number) => [
`At least ${minLength} characters`,
'One lowercase letter',
'One uppercase letter',
'One number',
'One special character',
];
/** `labels`, when given, overrides the default English text in the same
* order — the caller's translated strings, so this stays a plain function
* usable from a zod schema with no i18n context of its own. */
export function passwordRules(minLength: number, labels?: string[]) {
const text = labels ?? DEFAULT_LABELS(minLength);
return [
{ label: `At least ${minLength} characters`, test: (p: string) => p.length >= minLength },
{ label: 'One lowercase letter', test: (p: string) => /[a-z]/.test(p) },
{ label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
{ label: 'One number', test: (p: string) => /\d/.test(p) },
{ label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) },
{ label: text[0], test: (p: string) => p.length >= minLength },
{ label: text[1], test: (p: string) => /[a-z]/.test(p) },
{ label: text[2], test: (p: string) => /[A-Z]/.test(p) },
{ label: text[3], test: (p: string) => /\d/.test(p) },
{ label: text[4], test: (p: string) => /[^A-Za-z0-9]/.test(p) },
];
}
/** Zod field schema enforcing every rule; unmet rules surface as separate issues. */
export const passwordSchema = (minLength: number) =>
export const passwordSchema = (minLength: number, labels?: string[]) =>
z.string().superRefine((val, ctx) => {
for (const rule of passwordRules(minLength)) {
for (const rule of passwordRules(minLength, labels)) {
if (!rule.test(val)) {
ctx.addIssue({ code: 'custom', message: rule.label });
}
@@ -34,10 +46,10 @@ interface PasswordRequirementsProps {
/** Live checklist of password requirements, ticking off as the user types. Hidden until the user starts typing. */
export function PasswordRequirements({ password, minLength, labels }: PasswordRequirementsProps) {
if (!password) return null;
const rules = passwordRules(minLength);
const rules = passwordRules(minLength, labels);
return (
<Stack gap={6} mt={6}>
{rules.map((rule, i) => {
{rules.map((rule) => {
const met = rule.test(password);
return (
<Group key={rule.label} gap={6} wrap="nowrap">
@@ -47,7 +59,7 @@ export function PasswordRequirements({ password, minLength, labels }: PasswordRe
<IconX size={14} color="var(--mantine-color-gray-5)" />
)}
<Text fz="xs" c={met ? 'teal' : 'dimmed'}>
{labels?.[i] ?? rule.label}
{rule.label}
</Text>
</Group>
);

View File

@@ -0,0 +1,718 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Accordion,
Anchor,
Box,
Button,
Container,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
useMantineTheme,
} from '@mantine/core';
import {
IconActivity,
IconAnchor,
IconAward,
IconBriefcase,
IconCertificate,
IconClipboardCheck,
IconGavel,
IconLanguage,
IconMail,
IconMapPin,
IconPhone,
IconSchool,
IconShip,
IconShieldCheck,
IconUserCheck,
IconUserCircle,
IconUsers,
} from '@tabler/icons-react';
import { LanguageSwitcher } from '../layout/LanguageSwitcher';
import { ColorSchemeToggle } from '../layout/ColorSchemeToggle';
import './landing.css';
export interface LandingPageProps {
/** Primary CTA target. Each app passes '/dashboard' when a session token
* exists, else '/login'. Authenticated header state is derived from this
* (anything other than '/login' counts as signed in) rather than a
* separate prop, since libs/ui cannot import @ema-platform/auth. */
primaryHref: string;
/** Renders the "Create account" CTA only when set. Portal passes '/signup'; backoffice omits it (enableSignup: false). */
signupHref?: string;
supportedLanguages?: readonly string[];
}
const HEADER_HEIGHT = 72;
// Sections with a nav item, in page order — drives the scrollspy underline.
const NAV_SECTION_IDS = ['home', 'about', 'services', 'system', 'contact'] as const;
/** Tracks which nav section is currently under the sticky header, so the
* header can underline it. Native IntersectionObserver — no scroll listener. */
function useActiveSection(ids: readonly string[]) {
const [active, setActive] = useState<string>(ids[0]);
useEffect(() => {
const elements = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (!elements.length) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((entry) => entry.isIntersecting);
if (visible.length === 0) return;
const topMost = visible.reduce((a, b) => (a.boundingClientRect.top <= b.boundingClientRect.top ? a : b));
setActive(topMost.target.id);
},
// Counts a section only once it has cleared the sticky header, and only
// while it's still in the top 30% of the viewport.
{ rootMargin: `-${HEADER_HEIGHT}px 0px -70% 0px`, threshold: 0 },
);
elements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [ids]);
return active;
}
export function LandingPage({
primaryHref,
signupHref,
supportedLanguages = ['en', 'am'],
}: LandingPageProps) {
const { t } = useTranslation();
const isAuthed = primaryHref !== '/login';
return (
<Box className="ema-landing" style={{ minHeight: '100dvh', background: 'var(--mantine-color-body)' }}>
<Anchor
href="#main"
style={{
position: 'absolute',
left: 8,
top: -60,
zIndex: 1000,
padding: '10px 16px',
background: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 8,
transition: 'top 120ms ease',
}}
onFocus={(e) => {
e.currentTarget.style.top = '8px';
}}
onBlur={(e) => {
e.currentTarget.style.top = '-60px';
}}
>
{t('landing.meta.skipToContent')}
</Anchor>
<Header
isAuthed={isAuthed}
primaryHref={primaryHref}
signupHref={signupHref}
supportedLanguages={supportedLanguages}
/>
<main id="main">
<Hero primaryHref={primaryHref} signupHref={signupHref} isAuthed={isAuthed} />
<About />
<QuickAccess />
<Services />
<Roles />
<HowItWorks />
<SystemHighlights />
<Faq />
<Contact />
</main>
<Footer />
</Box>
);
}
function NavAnchor({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Anchor
href={href}
underline="never"
aria-current={active ? 'true' : undefined}
c={active ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'}
fw={500}
fz="sm"
style={{
whiteSpace: 'nowrap',
paddingBottom: 4,
borderBottom: active ? '2px solid var(--mantine-primary-color-filled)' : '2px solid transparent',
transition: 'color 120ms ease, border-color 120ms ease',
}}
>
{children}
</Anchor>
);
}
function Header({
isAuthed,
primaryHref,
signupHref,
supportedLanguages,
}: {
isAuthed: boolean;
primaryHref: string;
signupHref?: string;
supportedLanguages: readonly string[];
}) {
const { t } = useTranslation();
const activeSection = useActiveSection(NAV_SECTION_IDS);
return (
<Box
component="header"
style={{
position: 'sticky',
top: 0,
zIndex: 100,
height: HEADER_HEIGHT,
background: 'var(--mantine-color-body)',
borderBottom: '1px solid var(--mantine-color-default-border)',
}}
>
<Container size="xl" h="100%">
<Group h="100%" justify="space-between" wrap="nowrap" gap="md">
<Anchor href="#home" underline="never" c="var(--mantine-color-text)">
<Group gap="xs" wrap="nowrap">
<Box component="img" src="/ema-logo.png" alt="" w={34} h={34} style={{ objectFit: 'contain' }} />
<Text fw={700} fz="sm" visibleFrom="sm">
{t('app.name')}
</Text>
</Group>
</Anchor>
<Group gap="lg" wrap="nowrap" visibleFrom="md" component="nav" aria-label={t('landing.nav.home')}>
<NavAnchor href="#home" active={activeSection === 'home'}>
{t('landing.nav.home')}
</NavAnchor>
<NavAnchor href="#about" active={activeSection === 'about'}>
{t('landing.nav.about')}
</NavAnchor>
<NavAnchor href="#services" active={activeSection === 'services'}>
{t('landing.nav.services')}
</NavAnchor>
<NavAnchor href="#system" active={activeSection === 'system'}>
{t('landing.nav.system')}
</NavAnchor>
<NavAnchor href="#contact" active={activeSection === 'contact'}>
{t('landing.nav.contact')}
</NavAnchor>
</Group>
<Group gap="xs" wrap="nowrap">
<LanguageSwitcher supportedLanguages={supportedLanguages} />
<ColorSchemeToggle />
{isAuthed ? (
<Button component={Link} to={primaryHref} size="sm" visibleFrom="xs">
{t('landing.auth.dashboard')}
</Button>
) : (
<>
{/* Login is the secondary action next to Sign Up when both
exist (portal) — but when there's no signup (backoffice,
enableSignup: false), it's the only action, so it takes
the filled/primary treatment instead of reading like a
leftover half of a pair. */}
<Button
component={Link}
to={primaryHref}
variant={signupHref ? 'subtle' : 'filled'}
size="sm"
visibleFrom="xs"
>
{t('landing.auth.login')}
</Button>
{signupHref && (
<Button component={Link} to={signupHref} size="sm" visibleFrom="xs">
{t('landing.auth.signup')}
</Button>
)}
</>
)}
</Group>
</Group>
</Container>
</Box>
);
}
const HERO_BADGE_ICONS = {
secure: IconShieldCheck,
bilingual: IconLanguage,
tracking: IconActivity,
} as const;
function Hero({
primaryHref,
signupHref,
isAuthed,
}: {
primaryHref: string;
signupHref?: string;
isAuthed: boolean;
}) {
const { t } = useTranslation();
const theme = useMantineTheme();
const badgeKeys = Object.keys(HERO_BADGE_ICONS) as (keyof typeof HERO_BADGE_ICONS)[];
return (
<Box
id="home"
component="section"
pos="relative"
style={{
background: theme.other?.heroGradient as string,
color: 'white',
overflow: 'hidden',
}}
>
{/* Decorative glow circles — same device as AuthShell's hero panel. */}
<Box
pos="absolute"
top={-120}
right={-100}
w={420}
h={420}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.08)' }}
/>
<Box
pos="absolute"
bottom={-160}
left={-120}
w={380}
h={380}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }}
/>
<Box
pos="absolute"
top="30%"
left="8%"
w={140}
h={140}
visibleFrom="md"
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.05)' }}
/>
<Container size="xl" py={{ base: 88, sm: 120, lg: 148 }} pos="relative">
<Stack gap="lg" align="center" ta="center" className="ema-landing-fade">
<Text fz="sm" fw={600} style={{ opacity: 0.85, letterSpacing: 0.5 }}>
{t('landing.hero.eyebrow')}
</Text>
<Title order={1} fz={{ base: 34, sm: 48, lg: 56 }} lh={1.1} maw={860}>
{t('landing.hero.title')}
</Title>
<Text fz={{ base: 'md', sm: 'xl' }} fw={500} maw={680} style={{ opacity: 0.95 }}>
{t('landing.hero.subtitle')}
</Text>
<Text fz={{ base: 'sm', sm: 'md' }} maw={600} style={{ opacity: 0.82 }}>
{t('landing.hero.description')}
</Text>
<Group justify="center">
<Button
component={Link}
to={isAuthed ? primaryHref : (signupHref ?? '/login')}
size="lg"
variant="white"
color="dark"
leftSection={<IconShip size={20} />}
style={{ boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}
>
{isAuthed ? t('landing.auth.dashboard') : t('landing.cta.getStarted')}
</Button>
</Group>
<Group gap="xs" mt="xl" justify="center" wrap="wrap">
{badgeKeys.map((key) => {
const Icon = HERO_BADGE_ICONS[key];
return (
<Group
key={key}
gap={6}
wrap="nowrap"
px="sm"
py={6}
style={{
borderRadius: 999,
background: 'rgba(255,255,255,0.12)',
border: '1px solid rgba(255,255,255,0.18)',
}}
>
<Icon size={14} />
<Text fz="xs" fw={600}>
{t(`landing.hero.badges.${key}`)}
</Text>
</Group>
);
})}
</Group>
</Stack>
</Container>
</Box>
);
}
function SectionHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack gap={6} align="center" ta="center" mb="xl" maw={640} mx="auto">
<Title order={2} fz={{ base: 24, sm: 30 }}>
{title}
</Title>
{subtitle && (
<Text c="dimmed" fz="md">
{subtitle}
</Text>
)}
</Stack>
);
}
function About() {
const { t } = useTranslation();
return (
<Box id="about" component="section" py={72}>
<Container size="xl">
<SectionHeading title={t('landing.about.title')} />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xl">
<Paper withBorder radius="lg" p="xl">
<Stack gap="xs">
<Text fw={700} c="var(--mantine-primary-color-filled)" tt="uppercase" fz="xs" style={{ letterSpacing: 0.5 }}>
{t('landing.about.visionLabel')}
</Text>
<Text>{t('landing.about.vision')}</Text>
</Stack>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Stack gap="xs">
<Text fw={700} c="var(--mantine-primary-color-filled)" tt="uppercase" fz="xs" style={{ letterSpacing: 0.5 }}>
{t('landing.about.missionLabel')}
</Text>
<Text>{t('landing.about.mission')}</Text>
</Stack>
</Paper>
</SimpleGrid>
</Container>
</Box>
);
}
function QuickAccessCard({
icon: Icon,
title,
description,
href,
}: {
icon: typeof IconCertificate;
title: string;
description: string;
href?: string;
}) {
const body = (
<Stack gap="sm">
<ThemeIcon size={44} radius="md" variant="light">
<Icon size={22} />
</ThemeIcon>
<Text fw={600}>{title}</Text>
<Text c="dimmed" fz="sm">
{description}
</Text>
</Stack>
);
const cardStyle = { display: 'block', height: '100%', textDecoration: 'none', color: 'inherit' } as const;
return href ? (
<Paper component={Link} to={href} withBorder radius="lg" p="lg" className="ema-landing-hover" style={cardStyle}>
{body}
</Paper>
) : (
<Paper withBorder radius="lg" p="lg" className="ema-landing-hover" style={cardStyle}>
{body}
</Paper>
);
}
function QuickAccess() {
const { t } = useTranslation();
return (
<Box id="quick-access" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
<Container size="xl">
<SectionHeading title={t('landing.quickAccess.title')} subtitle={t('landing.quickAccess.subtitle')} />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<QuickAccessCard
icon={IconShip}
title={t('landing.quickAccess.vesselRegistration.title')}
description={t('landing.quickAccess.vesselRegistration.description')}
/>
<QuickAccessCard
icon={IconAnchor}
title={t('landing.quickAccess.notices.title')}
description={t('landing.quickAccess.notices.description')}
/>
</SimpleGrid>
</Container>
</Box>
);
}
const SERVICE_ICONS = {
seafarerRegistration: IconUserCheck,
vesselRegistration: IconShip,
licensing: IconBriefcase,
examinations: IconSchool,
waivers: IconShieldCheck,
} as const;
function Services() {
const { t } = useTranslation();
const items = Object.keys(SERVICE_ICONS) as (keyof typeof SERVICE_ICONS)[];
return (
<Box id="services" component="section" py={72}>
<Container size="xl">
<SectionHeading title={t('landing.services.title')} subtitle={t('landing.services.subtitle')} />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{items.map((key) => {
const Icon = SERVICE_ICONS[key];
return (
<Paper key={key} withBorder radius="lg" p="lg">
<Stack gap="sm">
<ThemeIcon size={44} radius="md" variant="light">
<Icon size={22} />
</ThemeIcon>
<Text fw={600}>{t(`landing.services.items.${key}.title`)}</Text>
<Text c="dimmed" fz="sm">
{t(`landing.services.items.${key}.description`)}
</Text>
</Stack>
</Paper>
);
})}
</SimpleGrid>
</Container>
</Box>
);
}
const ROLE_ICONS = {
seafarers: IconShip,
vesselOwners: IconAnchor,
agents: IconBriefcase,
reviewers: IconGavel,
} as const;
function Roles() {
const { t } = useTranslation();
const items = Object.keys(ROLE_ICONS) as (keyof typeof ROLE_ICONS)[];
return (
<Box id="roles" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
<Container size="xl">
<SectionHeading title={t('landing.roles.title')} subtitle={t('landing.roles.subtitle')} />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{items.map((key) => {
const Icon = ROLE_ICONS[key];
return (
<Paper key={key} withBorder radius="lg" p="lg" ta="center">
<Stack gap="sm" align="center">
<ThemeIcon size={48} radius="xl" variant="light">
<Icon size={24} />
</ThemeIcon>
<Text fw={600}>{t(`landing.roles.${key}.title`)}</Text>
<Text c="dimmed" fz="sm">
{t(`landing.roles.${key}.description`)}
</Text>
</Stack>
</Paper>
);
})}
</SimpleGrid>
</Container>
</Box>
);
}
const STEP_KEYS = ['selectRole', 'createAccount', 'submitApplication', 'trackStatus', 'receiveApproval'] as const;
const STEP_ICONS = [IconUsers, IconUserCheck, IconClipboardCheck, IconAward, IconCertificate];
function HowItWorks() {
const { t } = useTranslation();
return (
<Box id="how-it-works" component="section" py={72}>
<Container size="xl">
<SectionHeading title={t('landing.howItWorks.title')} subtitle={t('landing.howItWorks.subtitle')} />
<SimpleGrid cols={{ base: 1, sm: 5 }} spacing="lg">
{STEP_KEYS.map((key, i) => {
const Icon = STEP_ICONS[i];
return (
<Stack key={key} gap="xs" align="center" ta="center">
<ThemeIcon size={48} radius="xl" variant="filled" color="var(--mantine-primary-color-filled)">
<Icon size={22} />
</ThemeIcon>
<Text fw={600} fz="sm">
{i + 1}. {t(`landing.howItWorks.steps.${key}`)}
</Text>
</Stack>
);
})}
</SimpleGrid>
</Container>
</Box>
);
}
const SYSTEM_ICONS = {
secure: IconShieldCheck,
bilingual: IconLanguage,
tracking: IconActivity,
singleAccount: IconUserCircle,
} as const;
function SystemHighlights() {
const { t } = useTranslation();
const items = Object.keys(SYSTEM_ICONS) as (keyof typeof SYSTEM_ICONS)[];
return (
<Box id="system" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
<Container size="xl">
<SectionHeading title={t('landing.system.title')} subtitle={t('landing.system.subtitle')} />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{items.map((key) => {
const Icon = SYSTEM_ICONS[key];
return (
<Paper key={key} withBorder radius="lg" p="lg" ta="center">
<Stack gap="sm" align="center">
<ThemeIcon size={48} radius="xl" variant="light">
<Icon size={24} />
</ThemeIcon>
<Text fw={600}>{t(`landing.system.items.${key}.title`)}</Text>
<Text c="dimmed" fz="sm">
{t(`landing.system.items.${key}.description`)}
</Text>
</Stack>
</Paper>
);
})}
</SimpleGrid>
</Container>
</Box>
);
}
const FAQ_KEYS = ['whatIsPortal', 'whoCanUse', 'howToApply', 'isFree'] as const;
function Faq() {
const { t } = useTranslation();
return (
<Box component="section" py={72}>
<Container size="sm">
<SectionHeading title={t('landing.faq.title')} />
<Accordion variant="separated" radius="lg">
{FAQ_KEYS.map((key) => (
<Accordion.Item key={key} value={key}>
<Accordion.Control>{t(`landing.faq.items.${key}.question`)}</Accordion.Control>
<Accordion.Panel>{t(`landing.faq.items.${key}.answer`)}</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Container>
</Box>
);
}
function Contact() {
const { t } = useTranslation();
return (
<Box id="contact" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
<Container size="xl">
<SectionHeading title={t('landing.contact.title')} subtitle={t('landing.contact.subtitle')} />
<Paper withBorder radius="lg" p="xl" maw={560} mx="auto" component="address" style={{ fontStyle: 'normal' }}>
<Stack gap="lg">
<Group gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon size={38} radius="md" variant="light">
<IconMapPin size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} fz="sm">
{t('landing.contact.addressLabel')}
</Text>
<Text c="dimmed" fz="sm">
{t('landing.contact.address')}
</Text>
</Stack>
</Group>
<Group gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon size={38} radius="md" variant="light">
<IconPhone size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} fz="sm">
{t('landing.contact.phoneLabel')}
</Text>
<Anchor href="tel:+251115150299" fz="sm">
+251 011 515 0299
</Anchor>
</Stack>
</Group>
<Group gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon size={38} radius="md" variant="light">
<IconMail size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} fz="sm">
{t('landing.contact.websiteLabel')}
</Text>
<Anchor href="https://etmaritime.com" target="_blank" rel="noreferrer" fz="sm">
etmaritime.com
</Anchor>
</Stack>
</Group>
</Stack>
</Paper>
</Container>
</Box>
);
}
function Footer() {
const { t } = useTranslation();
return (
<Box component="footer" py="xl" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
<Container size="xl">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs">
<Box component="img" src="/ema-logo.png" alt="" w={24} h={24} style={{ objectFit: 'contain' }} />
<Text fz="sm" c="dimmed">
{t('landing.hero.title')}
</Text>
</Group>
<Text fz="sm" c="dimmed">
© 2026 {t('landing.hero.title')} {t('landing.footer.rights')}
</Text>
</Group>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,377 @@
/**
* Copy for the public landing page (`LandingPage.tsx`), shared by both apps.
* Written once here; each app's `en.ts`/`am.ts` re-exports it under the
* `landing` key so it flows through that app's own i18next instance.
*
* `landingAm: LandingCopy` enforces English/Amharic parity right here — a
* missing Amharic key is a compile error, same contract as each app's own
* `Translations = typeof en`.
*/
export const landingEn = {
meta: {
skipToContent: 'Skip to content',
},
nav: {
home: 'Home',
about: 'About EMA',
services: 'Services',
system: 'Our System',
contact: 'Contact',
},
auth: {
login: 'Login',
signup: 'Sign Up',
dashboard: 'Go to Dashboard',
},
hero: {
eyebrow: 'Federal Democratic Republic of Ethiopia',
title: 'Ethiopian Maritime Authority',
subtitle: 'Digital Maritime Services for Seafarers, Vessel Owners and Logistics Operators',
description:
'Apply, upload documents and track every application from one secure account — built for seafarers, vessel owners and logistics operators across Ethiopia.',
badges: {
secure: 'Secure & Verified',
bilingual: 'English & አማርኛ',
tracking: 'Real-Time Tracking',
},
},
about: {
title: 'About EMA',
visionLabel: 'Vision',
vision:
'To make Ethiopia the leading logistics performer in Africa and one of the five supplying seafarer nations in the world by 2030.',
missionLabel: 'Mission',
mission:
"Transform Ethiopia's logistics system and unlock the blue economy — strengthening legal frameworks, building infrastructure, and ensuring vessel safety and seafarer qualification.",
},
cta: {
getStarted: 'Get Started',
},
quickAccess: {
title: 'Quick Access',
subtitle: 'The most requested services, one click away.',
vesselRegistration: {
title: 'Vessel Registration',
description: 'Register a vessel or transfer ownership.',
},
notices: {
title: 'Marine Notices',
description: 'Official notices to seafarers, owners and operators.',
},
},
services: {
title: 'Maritime Services',
subtitle: 'Every licence, certificate and registration EMA issues, in one digital system.',
items: {
seafarerRegistration: {
title: 'Seafarer Registration & Certification',
description:
"Register as a seafarer, apply for a Certificate of Competency or Proficiency, and manage your seaman's book online.",
},
vesselRegistration: {
title: 'Vessel Registration',
description:
'Register inland or sea-going vessels and manage ownership transfers with full document tracking.',
},
licensing: {
title: 'Operator Licensing',
description:
'Apply for freight forwarder, shipping agent, combined and multimodal transport operator licences.',
},
examinations: {
title: 'Examinations',
description: 'Sit competency examinations and track your results as part of certification.',
},
waivers: {
title: 'Waivers',
description: 'Apply for a maritime waiver where standard requirements do not apply.',
},
},
},
roles: {
title: 'Built for Every User',
subtitle: 'One portal, tailored to what you do.',
seafarers: {
title: 'Seafarers',
description: 'Register, certify, and manage your sea service records.',
},
vesselOwners: {
title: 'Vessel Owners',
description: 'Register vessels and manage ownership and licensing.',
},
agents: {
title: 'Agents & Logistics Operators',
description: 'Apply for and renew operator licences for freight and shipping.',
},
reviewers: {
title: 'EMA Reviewers',
description: 'Review, verify and approve applications from the backoffice.',
},
},
howItWorks: {
title: 'How It Works',
subtitle: 'From application to approval, in five steps.',
steps: {
selectRole: 'Select role',
createAccount: 'Create account',
submitApplication: 'Submit application',
trackStatus: 'Track status',
receiveApproval: 'Receive approval',
},
},
system: {
title: 'A Modern Digital System',
subtitle: 'Built to make maritime services faster, safer and accessible from anywhere.',
items: {
secure: {
title: 'Secure & Verified',
description: 'Every application and certificate is digitally recorded and verifiable.',
},
bilingual: {
title: 'Bilingual by Design',
description: 'Use the system fully in English or Amharic — switch anytime.',
},
tracking: {
title: 'Real-Time Tracking',
description: 'Follow your application from submission to approval, step by step.',
},
singleAccount: {
title: 'One Account, Every Service',
description: 'Register once and access all EMA licensing and certification services.',
},
},
},
faq: {
title: 'Frequently Asked Questions',
items: {
whatIsPortal: {
question: 'What is the EMA portal?',
answer:
"The EMA portal is the Ethiopian Maritime Authority's official digital system for seafarer registration, vessel registration and licensing.",
},
whoCanUse: {
question: 'Who can use this portal?',
answer:
'Seafarers, vessel owners, shipping agents, freight forwarders and logistics operators can all apply for and manage their licences here.',
},
howToApply: {
question: 'How do I apply for a licence or certificate?',
answer:
'Create an account, select your role, and follow the application wizard for the licence or certificate you need. You can track its status at any time.',
},
isFree: {
question: 'Is registration free?',
answer:
'Creating a portal account is free. Statutory fees apply to specific licences and certificates, and are shown before you submit an application.',
},
},
},
contact: {
title: 'Contact EMA',
subtitle: 'Reach the Ethiopian Maritime Authority head office.',
addressLabel: 'Address',
address: 'Meskel Square, behind Hyatt Regency Hotel, Sunshine Building No. 4, Addis Ababa, Ethiopia',
phoneLabel: 'Phone',
websiteLabel: 'Website',
},
footer: {
rights: 'All rights reserved.',
},
};
export type LandingCopy = typeof landingEn;
export const landingAm: LandingCopy = {
meta: {
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
},
nav: {
home: 'ዋና ገጽ',
about: 'ስለ ባለስልጣኑ',
services: 'አገልግሎቶች',
system: 'ስርዓታችን',
contact: 'እኛን ያግኙ',
},
auth: {
login: 'ግባ',
signup: 'ይመዝገቡ',
dashboard: 'ወደ ዳሽቦርድ ይሂዱ',
},
hero: {
eyebrow: 'የኢትዮጵያ ፌዴራላዊ ዲሞክራሲያዊ ሪፐብሊክ',
title: 'የኢትዮጵያ ማሪታይም ባለስልጣን',
subtitle: 'ለመርከበኞች፣ ለመርከብ ባለቤቶች እና ለሎጂስቲክስ ኦፕሬተሮች የተዘጋጁ ዲጂታል የባህር አገልግሎቶች',
description:
'ከመለያዎ ማመልከቻ ያስገቡ፣ ሰነድ ይስቀሉ እንዲሁም ሁኔታ ይከታተሉ — ለመርከበኞች፣ ለመርከብ ባለቤቶችና ለሎጂስቲክስ ኦፕሬተሮች የተዘጋጀ ነው።',
badges: {
secure: 'ደህንነቱ የተጠበቀ',
bilingual: 'አማርኛና እንግሊዝኛ',
tracking: 'የቀጥታ ክትትል',
},
},
about: {
title: 'ስለ ባለስልጣኑ',
visionLabel: 'ራዕይ',
vision:
'በ2030 ኢትዮጵያ በአፍሪካ ግንባር ቀደም የሎጂስቲክስ አገልግሎት ሰጪ እንድትሆን፣ እንዲሁም ከዓለም አምስት መርከበኞችን ወደ ውጭ ከሚልኩ ሀገራት አንዷ እንድትሆን ማድረግ።',
missionLabel: 'ተልዕኮ',
mission:
'የኢትዮጵያን የሎጂስቲክስ ስርዓት መለወጥ እንዲሁም ከባህር ኢኮኖሚ ተጠቃሚ መሆን — በህግ ማዕቀፎች ማጠናከር፣ መሠረተ ልማት በመገንባትና የመርከብ ደህንነትንና የመርከበኛ ብቃትን በማረጋገጥ።',
},
cta: {
getStarted: 'ይጀምሩ',
},
quickAccess: {
title: 'ፈጣን መዳረሻ',
subtitle: 'በብዛት የሚፈለጉ አገልግሎቶች፣ በአንድ ቦታ።',
vesselRegistration: {
title: 'የመርከብ ምዝገባ',
description: 'መርከብ ይመዝገቡ ወይም ባለቤትነት ያስተላልፉ።',
},
notices: {
title: 'የባህር ማስታወቂያዎች',
description: 'ለመርከበኞች፣ ለመርከብ ባለቤቶችና ለኦፕሬተሮች የተሰጡ ማስታወቂያዎች።',
},
},
services: {
title: 'የባህር አገልግሎቶች',
subtitle: 'ባለስልጣኑ የሚሰጣቸው ሁሉም ፈቃድ፣ ምስክር ወረቀትና ምዝገባ በአንድ ዲጂታል ስርዓት ውስጥ።',
items: {
seafarerRegistration: {
title: 'የመርከበኞች ምዝገባና ማረጋገጫ',
description:
'እንደ መርከበኛ ይመዝገቡ፣ ለብቃት ወይም ችሎታ ማረጋገጫ ምስክር ወረቀት ያመልክቱ፣ እንዲሁም የመርከበኛ መዝገብ መጽሐፍዎን በመስመር ላይ ያስተዳድሩ።',
},
vesselRegistration: {
title: 'የመርከብ ምዝገባ',
description: 'የውስጥ ውሃ ወይም የባህር ማዶ መርከቦችን ይመዝገቡ፣ የባለቤትነት ዝውውርንም ሙሉ በሙሉ በሰነድ ክትትል ያስተዳድሩ።',
},
licensing: {
title: 'የኦፕሬተር ፈቃድ',
description: 'ለጭነት አስተላላፊ፣ ለመርከብ ወኪል፣ ለተቀናጀ እና ለብዙ-ዘዴ ትራንስፖርት ኦፕሬተር ፈቃድ ያመልክቱ።',
},
examinations: {
title: 'ፈተናዎች',
description: 'የብቃት ፈተናዎችን ይውሰዱ እንዲሁም ውጤቶችዎን እንደ ማረጋገጫ ሂደት አካል ይከታተሉ።',
},
waivers: {
title: 'ነፃ ፈቃዶች',
description: 'መደበኛ መስፈርቶች በማይሟሉበት ጊዜ ለባህር ትራንስፖርት ነፃ ፈቃድ ያመልክቱ።',
},
},
},
roles: {
title: 'ለሁሉም ተጠቃሚ የተዘጋጀ',
subtitle: 'አንድ ፖርታል፣ ለሚናዎ የተዘጋጀ።',
seafarers: {
title: 'መርከበኞች',
description: 'ይመዝገቡ፣ ይረጋገጡ እንዲሁም የባህር አገልግሎት መዝገብዎን ያስተዳድሩ።',
},
vesselOwners: {
title: 'የመርከብ ባለቤቶች',
description: 'መርከብ ይመዝገቡ እንዲሁም ባለቤትነትና ፈቃድ ያስተዳድሩ።',
},
agents: {
title: 'ወኪሎችና ሎጂስቲክስ ኦፕሬተሮች',
description: 'ለጭነትና ለመላኪያ ፈቃድ ያመልክቱ እንዲሁም ያድሱ።',
},
reviewers: {
title: 'የባለስልጣኑ ገምጋሚዎች',
description: 'ማመልከቻዎችን ይገመግሙ፣ ያረጋግጡ እንዲሁም ይፍቀዱ።',
},
},
howItWorks: {
title: 'እንዴት እንደሚሰራ',
subtitle: 'ከማመልከቻ እስከ ፈቃድ፣ በአምስት ደረጃዎች።',
steps: {
selectRole: 'ሚና ይምረጡ',
createAccount: 'መለያ ይፍጠሩ',
submitApplication: 'ማመልከቻ ያስገቡ',
trackStatus: 'ሁኔታ ይከታተሉ',
receiveApproval: 'ፍቃድ ይቀበሉ',
},
},
system: {
title: 'ዘመናዊ ዲጂታል ስርዓት',
subtitle: 'የባህር አገልግሎቶችን ፈጣን፣ ደህንነቱ የተጠበቀና ከየትኛውም ቦታ ተደራሽ ለማድረግ የተዘጋጀ።',
items: {
secure: {
title: 'ደህንነቱ የተጠበቀ እና የተረጋገጠ',
description: 'እያንዳንዱ ማመልከቻና ምስክር ወረቀት በዲጂታል መልኩ ተመዝግቦ ሊረጋገጥ የሚችል ነው።',
},
bilingual: {
title: 'በሁለት ቋንቋ የተዘጋጀ',
description: 'ስርዓቱን ሙሉ በሙሉ በአማርኛ ወይም በእንግሊዝኛ ይጠቀሙ — በማንኛውም ጊዜ ይቀይሩ።',
},
tracking: {
title: 'የቀጥታ ሁኔታ ክትትል',
description: 'ማመልከቻዎን ከማስገባት እስከ ማጽደቅ ደረጃ በደረጃ ይከታተሉ።',
},
singleAccount: {
title: 'አንድ መለያ፣ ሁሉም አገልግሎት',
description: 'አንድ ጊዜ ይመዝገቡና ሁሉንም የEMA ፈቃድና የምስክር ወረቀት አገልግሎቶች ይድረሱ።',
},
},
},
faq: {
title: 'ተደጋጋሚ ጥያቄዎች',
items: {
whatIsPortal: {
question: 'EMA ፖርታል ምንድን ነው?',
answer:
'EMA ፖርታል የኢትዮጵያ ማሪታይም ባለስልጣን ለመርከበኛ ምዝገባ፣ ለመርከብ ምዝገባና ፈቃድ የሚጠቀምበት ዲጂታል ስርዓት ነው።',
},
whoCanUse: {
question: 'ይህን ፖርታል ማን ሊጠቀም ይችላል?',
answer: 'መርከበኞች፣ የመርከብ ባለቤቶች፣ ወኪሎችና ሎጂስቲክስ ኦፕሬተሮች ሁሉም እዚህ ፈቃዳቸውን ማመልከትና ማስተዳደር ይችላሉ።',
},
howToApply: {
question: 'ለፈቃድ ወይም ለምስክር ወረቀት እንዴት አመለክታለሁ?',
answer:
'መለያ ይፍጠሩ፣ ሚናዎን ይምረጡ፣ እንዲሁም የሚያስፈልግዎትን ፈቃድ ወይም ምስክር ወረቀት ደረጃዎች ይከተሉ። ሁኔታውን በማንኛውም ጊዜ መከታተል ይችላሉ።',
},
isFree: {
question: 'ምዝገባ ነፃ ነው?',
answer: 'የፖርታል መለያ መክፈት ነፃ ነው። ለተወሰኑ ፈቃዶችና ምስክር ወረቀቶች የመንግስት ክፍያ ይኖራል፣ ማመልከቻ ከማስገባትዎ በፊት ይታያል።',
},
},
},
contact: {
title: 'ባለስልጣኑን ያግኙ',
subtitle: 'የኢትዮጵያ ማሪታይም ባለስልጣን ዋና መሥሪያ ቤትን ያግኙ።',
addressLabel: 'አድራሻ',
address: 'መስቀል አደባባይ፣ ከHyatt Regency ሆቴል ጀርባ፣ Sunshine ህንፃ ቁጥር 4፣ አዲስ አበባ፣ ኢትዮጵያ',
phoneLabel: 'ስልክ',
websiteLabel: 'ድረ ገጽ',
},
footer: {
rights: 'ሁሉም መብቶች የተጠበቁ ናቸው።',
},
};

View File

@@ -0,0 +1,59 @@
/* Public landing page — scoped under .ema-landing so nothing leaks into the
rest of either app. Neither app's global CSS (portal's .ema-page-enter /
.ema-hover-lift, portal's --ema-surface-*) is available here, so this file
is self-contained. */
/* `scroll-behavior` only affects the element that actually scrolls — for a
full page that's `html`, not this div, so it has to live here. Scoped with
:has() so it only applies while the landing page is mounted. */
html:has(.ema-landing) {
scroll-behavior: smooth;
}
.ema-landing section[id] {
/* Offsets anchor jumps by the sticky header height so the heading isn't
hidden underneath it. Keep in sync with the header's fixed height. */
scroll-margin-top: 72px;
}
.ema-landing .ema-landing-fade {
animation: ema-landing-fade-up 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes ema-landing-fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ema-landing .ema-landing-hover {
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.ema-landing .ema-landing-hover:hover {
transform: translateY(-3px);
}
/* Amharic runs 20-40% longer than English and falls back to a different font
(Inter has no Ethiopic glyphs), so it needs more vertical room. */
.ema-landing:lang(am) {
line-height: 1.7;
}
@media (prefers-reduced-motion: reduce) {
html:has(.ema-landing) {
scroll-behavior: auto;
}
.ema-landing .ema-landing-fade,
.ema-landing .ema-landing-hover {
animation: none;
transition: none;
}
}

View File

@@ -27,10 +27,10 @@ export function ColorSchemeToggle() {
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';

View File

@@ -38,10 +38,10 @@ export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: Langu
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';