mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into feature/exam-attempt-domain
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { landingAm } from "@ema-platform/ui";
|
||||
import type { Translations } from "./en";
|
||||
|
||||
export const am: Translations = {
|
||||
landing: landingAm,
|
||||
|
||||
app: {
|
||||
name: "ኢማ አስተዳደር",
|
||||
shortName: "ኢማ",
|
||||
@@ -1294,4 +1297,68 @@ export const am: Translations = {
|
||||
view: "ይመልከቱ",
|
||||
noUrl: "URL የለም",
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: "ገጽታ ቀይር",
|
||||
brandTitle: "የባህር ትራንስፖርት ፈቃድ አሰጣጥ ቀላል ሆኗል።",
|
||||
brandSubtitle: "ለመርከብ እና ለመርከበኛ ፈቃዶች ያመልክቱ፣ ሰነዶችን ይስቀሉ፣ እና እያንዳንዱን ማመልከቻ በአንድ ደህንነቱ በተጠበቀ መተግበሪያ ላይ ይከታተሉ።",
|
||||
feature1: "ማመልከቻዎችን በመስመር ላይ 24/7 ያስገቡ",
|
||||
feature2: "የቅጽበት ሁኔታ ክትትል እና ማንቂያ",
|
||||
feature3: "በእንግሊዝኛ እና በአማርኛ ይገኛል",
|
||||
copyright: "© 2026 የኢትዮጵያ የባህር ትራንስፖርት ባለስልጣን",
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||
emailOrPhoneLabel: "ኢሜይል ወይም ስልክ",
|
||||
emailOrPhonePlaceholder: "you@example.com",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "የይለፍ ቃልዎ",
|
||||
rememberMe: "አስታውሰኝ",
|
||||
forgotPassword: "የይለፍ ቃል ረሱ?",
|
||||
signIn: "ግባ",
|
||||
noAccount: "መለያ የለዎትም? ",
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
|
||||
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
|
||||
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
|
||||
brandTitle: "የ{{appName}} ማህበረሰብን ይቀላቀሉ።",
|
||||
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
|
||||
title: "መለያ ይፍጠሩ",
|
||||
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
|
||||
nameEnLabel: "ስም (እንግሊዝኛ)",
|
||||
nameEnPlaceholder: "አበበ በቀለ",
|
||||
nameAmLabel: "ስም (አማርኛ)",
|
||||
nameAmPlaceholder: "ስም",
|
||||
emailLabel: "ኢሜይል አድራሻ",
|
||||
emailPlaceholder: "you@example.com",
|
||||
usernameLabel: "የተጠቃሚ ስም",
|
||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||
phoneLabel: "ስልክ ቁጥር",
|
||||
phonePlaceholder: "+251 911 234 567",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||
confirmPasswordPlaceholder: "የይለፍ ቃል እንደገና ያስገቡ",
|
||||
agreeToThe: "እስማማለሁ ከ",
|
||||
termsAndPrivacy: "ውሎች እና የግላዊነት ፖሊሲ",
|
||||
createAccount: "መለያ ይፍጠሩ",
|
||||
haveAccount: "አስቀድሞ መለያ አለዎት? ",
|
||||
signIn: "ግባ",
|
||||
passwordRule: {
|
||||
minLength: "ቢያንስ {{min}} ቁምፊዎች",
|
||||
lowercase: "አንድ ትንሽ ፊደል",
|
||||
uppercase: "አንድ ትልቅ ፊደል",
|
||||
number: "አንድ ቁጥር",
|
||||
special: "አንድ ልዩ ምልክት",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { landingEn } from '@ema-platform/ui';
|
||||
|
||||
export const en = {
|
||||
landing: landingEn,
|
||||
|
||||
app: {
|
||||
name: 'EMA Admin',
|
||||
shortName: 'EMA',
|
||||
@@ -1290,6 +1294,70 @@ export const en = {
|
||||
view: 'View',
|
||||
noUrl: 'No URL',
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: 'Toggle theme',
|
||||
brandTitle: 'Maritime licensing, made simple.',
|
||||
brandSubtitle: 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
feature1: 'Submit applications online, 24/7',
|
||||
feature2: 'Real-time status tracking & alerts',
|
||||
feature3: 'Available in English & አማርኛ',
|
||||
copyright: '© 2026 Ethiopian Maritime Authority',
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
welcome: 'Welcome to {{appName}}',
|
||||
subtitle: 'Sign in to access your account.',
|
||||
emailOrPhoneLabel: 'Email or phone',
|
||||
emailOrPhonePlaceholder: 'you@example.com',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'Your password',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPassword: 'Forgot password?',
|
||||
signIn: 'Sign in',
|
||||
noAccount: "Don't have an account? ",
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
phoneRequired: 'Phone number is required',
|
||||
confirmPasswordRequired: 'Confirm your password',
|
||||
passwordsDontMatch: 'Passwords do not match',
|
||||
brandTitle: "Join {{appName}}'s community.",
|
||||
brandSubtitle: 'Create your account to access {{appName}} features.',
|
||||
title: 'Create account',
|
||||
subtitle: 'It only takes a minute to get started.',
|
||||
nameEnLabel: 'Name (English)',
|
||||
nameEnPlaceholder: 'Abebe Bekele',
|
||||
nameAmLabel: 'Name (Amharic)',
|
||||
nameAmPlaceholder: 'ስም',
|
||||
emailLabel: 'Email address',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
usernameLabel: 'Username',
|
||||
usernamePlaceholder: 'Choose a username',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+251 911 234 567',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordLabel: 'Confirm password',
|
||||
confirmPasswordPlaceholder: 'Re-enter password',
|
||||
agreeToThe: 'I agree to the ',
|
||||
termsAndPrivacy: 'Terms & Privacy Policy',
|
||||
createAccount: 'Create account',
|
||||
haveAccount: 'Already have an account? ',
|
||||
signIn: 'Sign in',
|
||||
passwordRule: {
|
||||
minLength: 'At least {{min}} characters',
|
||||
lowercase: 'One lowercase letter',
|
||||
uppercase: 'One uppercase letter',
|
||||
number: 'One number',
|
||||
special: 'One special character',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
13
apps/backoffice/src/app/router/LandingRoute.tsx
Normal file
13
apps/backoffice/src/app/router/LandingRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { LandingPage } from '@ema-platform/ui';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Public `/` — mounts the shared landing page. Backoffice has no /signup
|
||||
* (enableSignup: false) and no /verify route, so those props are omitted.
|
||||
*/
|
||||
export function LandingRoute() {
|
||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||
|
||||
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} />;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { LandingRoute } from './LandingRoute';
|
||||
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementPage from '../features/user-management/UserManagementPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
@@ -61,7 +62,7 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementPage /> },
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/', element: <LandingRoute /> },
|
||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
@@ -69,7 +70,6 @@ const router = createBrowserRouter([
|
||||
{
|
||||
element: <BackofficeLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
|
||||
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
|
||||
|
||||
@@ -39,7 +39,7 @@ window.__USER_MANAGEMENT_BRANDING__ = {
|
||||
organizationName: 'Ethiopian Maritime Authority',
|
||||
logoSrc: '/assets/emaLogo.jpg',
|
||||
logoAlt: 'EMA Logo',
|
||||
homePath: '/',
|
||||
homePath: '/dashboard',
|
||||
moduleBasePath: '/user-management',
|
||||
backToAppPath: '/dashboard',
|
||||
backToAppLabel: 'Back to dashboard',
|
||||
|
||||
15
apps/portal/src/app/components/LandingRoute.tsx
Normal file
15
apps/portal/src/app/components/LandingRoute.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { LandingPage } from '@ema-platform/ui';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Public `/` — mounts the shared landing page with portal-specific routes.
|
||||
* Auth state is read the same way ProtectedRoute does (token cookie or
|
||||
* storage fallback) so the header can show "Go to dashboard" instead of
|
||||
* Login/Sign Up without gating the route itself.
|
||||
*/
|
||||
export function LandingRoute() {
|
||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||
|
||||
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} signupHref="/signup" />;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useVerifyCertificateQuery } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Public certificate verification (US-PORTAL-008) — the page every printed
|
||||
* QR code points at. Deliberately public and chrome-free: a bank clerk or
|
||||
* port official scanning a certificate has no portal account.
|
||||
*/
|
||||
export function VerifyCertificatePage() {
|
||||
const { code } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [manualCode, setManualCode] = useState('');
|
||||
const { data, isLoading, isError } = useVerifyCertificateQuery(code ?? '', {
|
||||
skip: !code,
|
||||
});
|
||||
|
||||
const rows: [string, string | null | undefined][] = data?.valid
|
||||
? [
|
||||
['Certificate number', data.certificateNumber],
|
||||
['Licence / registration', data.licenseType],
|
||||
['Holder', data.companyName],
|
||||
['Issued', data.issueDate],
|
||||
['Expires', data.expiryDate],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Container size="xs" py="xl">
|
||||
<Stack align="center" gap="lg">
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={28} color="var(--mantine-color-blue-6)" />
|
||||
<Title order={3}>EMA Certificate Verification</Title>
|
||||
</Group>
|
||||
|
||||
{!code && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Scan the QR code on a certificate, or enter its verification
|
||||
code below.
|
||||
</Text>
|
||||
<Group>
|
||||
<TextInput
|
||||
flex={1}
|
||||
placeholder="Verification code"
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconSearch size={16} />}
|
||||
disabled={!manualCode.trim()}
|
||||
onClick={() => navigate(`/verify/${manualCode.trim()}`)}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{code && isLoading && (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{code && !isLoading && (isError || !data) && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Text c="red" ta="center">
|
||||
Verification is temporarily unavailable. Please try again.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{code && data && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Stack>
|
||||
{data.valid ? (
|
||||
<Group>
|
||||
<IconCircleCheck
|
||||
size={40}
|
||||
color="var(--mantine-color-green-6)"
|
||||
/>
|
||||
<div>
|
||||
<Text fw={700} size="lg" c="green">
|
||||
Valid certificate
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Issued by the Ethiopian Maritime Authority.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<IconCircleX size={40} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={700} size="lg" c="red">
|
||||
Not valid
|
||||
</Text>
|
||||
{data.status && <Badge color="red">{data.status}</Badge>}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.reason === 'not_found'
|
||||
? 'No certificate matches this code.'
|
||||
: 'This certificate is no longer valid.'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Table variant="vertical" layout="fixed">
|
||||
<Table.Tbody>
|
||||
{rows
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => (
|
||||
<Table.Tr key={label}>
|
||||
<Table.Th w={170}>{label}</Table.Th>
|
||||
<Table.Td>{value}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => {
|
||||
setManualCode('');
|
||||
navigate('/verify');
|
||||
}}
|
||||
>
|
||||
Verify another certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { landingAm } from '@ema-platform/ui';
|
||||
import type { Translations } from './en';
|
||||
|
||||
export const am: Translations = {
|
||||
landing: landingAm,
|
||||
|
||||
app: {
|
||||
name: 'ኢባባ ፖርታል',
|
||||
authority: 'የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን',
|
||||
@@ -386,4 +389,68 @@ export const am: Translations = {
|
||||
SUPERSEDED: "ተተክቷል",
|
||||
},
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: "ገጽታ ቀይር",
|
||||
brandTitle: "የባህር ትራንስፖርት ፈቃድ አሰጣጥ ቀላል ሆኗል።",
|
||||
brandSubtitle: "ለመርከብ እና ለመርከበኛ ፈቃዶች ያመልክቱ፣ ሰነዶችን ይስቀሉ፣ እና እያንዳንዱን ማመልከቻ በአንድ ደህንነቱ በተጠበቀ መተግበሪያ ላይ ይከታተሉ።",
|
||||
feature1: "ማመልከቻዎችን በመስመር ላይ 24/7 ያስገቡ",
|
||||
feature2: "የቅጽበት ሁኔታ ክትትል እና ማንቂያ",
|
||||
feature3: "በእንግሊዝኛ እና በአማርኛ ይገኛል",
|
||||
copyright: "© 2026 የኢትዮጵያ የባህር ትራንስፖርት ባለስልጣን",
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||
emailOrPhoneLabel: "ኢሜይል ወይም ስልክ",
|
||||
emailOrPhonePlaceholder: "you@example.com",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "የይለፍ ቃልዎ",
|
||||
rememberMe: "አስታውሰኝ",
|
||||
forgotPassword: "የይለፍ ቃል ረሱ?",
|
||||
signIn: "ግባ",
|
||||
noAccount: "መለያ የለዎትም? ",
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
|
||||
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
|
||||
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
|
||||
brandTitle: "የ{{appName}} ማህበረሰብን ይቀላቀሉ።",
|
||||
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
|
||||
title: "መለያ ይፍጠሩ",
|
||||
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
|
||||
nameEnLabel: "ስም (እንግሊዝኛ)",
|
||||
nameEnPlaceholder: "አበበ በቀለ",
|
||||
nameAmLabel: "ስም (አማርኛ)",
|
||||
nameAmPlaceholder: "ስም",
|
||||
emailLabel: "ኢሜይል አድራሻ",
|
||||
emailPlaceholder: "you@example.com",
|
||||
usernameLabel: "የተጠቃሚ ስም",
|
||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||
phoneLabel: "ስልክ ቁጥር",
|
||||
phonePlaceholder: "+251 911 234 567",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||
confirmPasswordPlaceholder: "የይለፍ ቃል እንደገና ያስገቡ",
|
||||
agreeToThe: "እስማማለሁ ከ",
|
||||
termsAndPrivacy: "ውሎች እና የግላዊነት ፖሊሲ",
|
||||
createAccount: "መለያ ይፍጠሩ",
|
||||
haveAccount: "አስቀድሞ መለያ አለዎት? ",
|
||||
signIn: "ግባ",
|
||||
passwordRule: {
|
||||
minLength: "ቢያንስ {{min}} ቁምፊዎች",
|
||||
lowercase: "አንድ ትንሽ ፊደል",
|
||||
uppercase: "አንድ ትልቅ ፊደል",
|
||||
number: "አንድ ቁጥር",
|
||||
special: "አንድ ልዩ ምልክት",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { landingEn } from '@ema-platform/ui';
|
||||
|
||||
export const en = {
|
||||
landing: landingEn,
|
||||
|
||||
app: {
|
||||
name: 'EMA Portal',
|
||||
authority: 'Ethiopian Maritime Authority',
|
||||
@@ -385,6 +389,70 @@ export const en = {
|
||||
SUPERSEDED: 'Superseded',
|
||||
},
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: 'Toggle theme',
|
||||
brandTitle: 'Maritime licensing, made simple.',
|
||||
brandSubtitle: 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
feature1: 'Submit applications online, 24/7',
|
||||
feature2: 'Real-time status tracking & alerts',
|
||||
feature3: 'Available in English & አማርኛ',
|
||||
copyright: '© 2026 Ethiopian Maritime Authority',
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
welcome: 'Welcome to {{appName}}',
|
||||
subtitle: 'Sign in to access your account.',
|
||||
emailOrPhoneLabel: 'Email or phone',
|
||||
emailOrPhonePlaceholder: 'you@example.com',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'Your password',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPassword: 'Forgot password?',
|
||||
signIn: 'Sign in',
|
||||
noAccount: "Don't have an account? ",
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
phoneRequired: 'Phone number is required',
|
||||
confirmPasswordRequired: 'Confirm your password',
|
||||
passwordsDontMatch: 'Passwords do not match',
|
||||
brandTitle: "Join {{appName}}'s community.",
|
||||
brandSubtitle: 'Create your account to access {{appName}} features.',
|
||||
title: 'Create account',
|
||||
subtitle: 'It only takes a minute to get started.',
|
||||
nameEnLabel: 'Name (English)',
|
||||
nameEnPlaceholder: 'Abebe Bekele',
|
||||
nameAmLabel: 'Name (Amharic)',
|
||||
nameAmPlaceholder: 'ስም',
|
||||
emailLabel: 'Email address',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
usernameLabel: 'Username',
|
||||
usernamePlaceholder: 'Choose a username',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+251 911 234 567',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordLabel: 'Confirm password',
|
||||
confirmPasswordPlaceholder: 'Re-enter password',
|
||||
agreeToThe: 'I agree to the ',
|
||||
termsAndPrivacy: 'Terms & Privacy Policy',
|
||||
createAccount: 'Create account',
|
||||
haveAccount: 'Already have an account? ',
|
||||
signIn: 'Sign in',
|
||||
passwordRule: {
|
||||
minLength: 'At least {{min}} characters',
|
||||
lowercase: 'One lowercase letter',
|
||||
uppercase: 'One uppercase letter',
|
||||
number: 'One number',
|
||||
special: 'One special character',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthBootstrap, AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { i18n } from '../i18n/config';
|
||||
import { MantineThemeProvider } from './MantineThemeProvider';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
|
||||
@@ -26,9 +28,11 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>
|
||||
<AuthBootstrap>{children}</AuthBootstrap>
|
||||
</MantineThemeProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<MantineThemeProvider>
|
||||
<AuthBootstrap>{children}</AuthBootstrap>
|
||||
</MantineThemeProvider>
|
||||
</I18nextProvider>
|
||||
</AuthConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { i18n } from "./i18n/config";
|
||||
import { PortalLayout } from "./layouts/PortalLayout";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { LandingRoute } from "./components/LandingRoute";
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import {
|
||||
@@ -30,7 +29,6 @@ import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
|
||||
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||
import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||
|
||||
@@ -59,15 +57,13 @@ import { WaiverPage } from "./features/waiver/pages/WaiverPage";
|
||||
import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public landing page — institutional overview + role-based entry points.
|
||||
{ path: "/", element: <LandingRoute /> },
|
||||
|
||||
// Public auth pages
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
|
||||
// Public certificate verification — the target of every printed QR code.
|
||||
// No auth: a verifier scanning a certificate has no portal account.
|
||||
{ path: "/verify", element: <VerifyCertificatePage /> },
|
||||
{ path: "/verify/:code", element: <VerifyCertificatePage /> },
|
||||
|
||||
// Completes the forgot-password flow; the reset message links here. The
|
||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||
// first-time-credential variant — one page serves both.
|
||||
@@ -101,17 +97,14 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{/* Asks what the applicant operates as before the rest of the
|
||||
portal, which is filtered by that answer. */}
|
||||
<RequireOperations>
|
||||
<PortalLayout />
|
||||
</RequireOperations>
|
||||
</I18nextProvider>
|
||||
{/* Asks what the applicant operates as before the rest of the
|
||||
portal, which is filtered by that answer. */}
|
||||
<RequireOperations>
|
||||
<PortalLayout />
|
||||
</RequireOperations>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: "/", element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: "/dashboard", element: <DashboardPage /> },
|
||||
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
|
||||
|
||||
@@ -418,5 +411,7 @@ export const router = createBrowserRouter([
|
||||
element: <Navigate to="/vessel-ownership-transfer" replace />,
|
||||
},
|
||||
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
// A typo'd URL should not maroon a signed-in user on the marketing page —
|
||||
// ProtectedRoute sends anonymous visitors on to /login exactly as before.
|
||||
{ path: "*", element: <Navigate to="/dashboard" replace /> },
|
||||
]);
|
||||
|
||||
@@ -308,27 +308,6 @@ export const licensingApi = baseApi
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------ licences
|
||||
/**
|
||||
* Public QR-code verification (US-PORTAL-008). No auth required —
|
||||
* the endpoint returns only what a verifier needs to trust the
|
||||
* document, never the holder's contact details.
|
||||
*/
|
||||
verifyCertificate: builder.query<
|
||||
{
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
certificateNumber?: string;
|
||||
licenseType?: string | null;
|
||||
companyName?: string | null;
|
||||
issueDate?: string;
|
||||
expiryDate?: string;
|
||||
status?: string;
|
||||
},
|
||||
string
|
||||
>({
|
||||
query: (code) => ({ url: `/licenses/verify/${code}` }),
|
||||
}),
|
||||
|
||||
/** The licence register for enforcement officers. */
|
||||
getLicenses: builder.query<
|
||||
Paginated<IssuedLicense>,
|
||||
@@ -769,7 +748,6 @@ export const licensingApi = baseApi
|
||||
});
|
||||
|
||||
export const {
|
||||
useVerifyCertificateQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type BoxProps,
|
||||
} from '@mantine/core';
|
||||
import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) {
|
||||
@@ -33,6 +34,7 @@ export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computed = useComputedColorScheme('light');
|
||||
const isDark = computed === 'dark';
|
||||
@@ -40,7 +42,7 @@ function ThemeToggle() {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
aria-label="Toggle theme"
|
||||
aria-label={t('authShell.toggleTheme', 'Toggle theme')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -60,28 +62,32 @@ function ThemeToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURES = [
|
||||
'Submit applications online, 24/7',
|
||||
'Real-time status tracking & alerts',
|
||||
'Available in English & አማርኛ',
|
||||
];
|
||||
|
||||
interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
brandTitle?: string;
|
||||
brandSubtitle?: string;
|
||||
}
|
||||
|
||||
export function AuthShell({
|
||||
children,
|
||||
brandTitle = 'Maritime licensing, made simple.',
|
||||
brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
}: AuthShellProps) {
|
||||
export function AuthShell({ children, brandTitle, brandSubtitle }: AuthShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { logoUrl } = useAuthConfig();
|
||||
const heroGradient = theme.other.heroGradient as string;
|
||||
const computed = useComputedColorScheme('light');
|
||||
const isDark = computed === 'dark';
|
||||
const resolvedBrandTitle =
|
||||
brandTitle ?? t('authShell.brandTitle', 'Maritime licensing, made simple.');
|
||||
const resolvedBrandSubtitle =
|
||||
brandSubtitle ??
|
||||
t(
|
||||
'authShell.brandSubtitle',
|
||||
'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
);
|
||||
const FEATURES = [
|
||||
t('authShell.feature1', 'Submit applications online, 24/7'),
|
||||
t('authShell.feature2', 'Real-time status tracking & alerts'),
|
||||
t('authShell.feature3', 'Available in English & አማርኛ'),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -175,10 +181,10 @@ export function AuthShell({
|
||||
|
||||
<Stack gap={6}>
|
||||
<Title order={2} c="white" fz={rem(28)} lh={1.2} fw={700}>
|
||||
{brandTitle}
|
||||
{resolvedBrandTitle}
|
||||
</Title>
|
||||
<Text fz="sm" lh={1.6} style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||
{brandSubtitle}
|
||||
{resolvedBrandSubtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -204,7 +210,7 @@ export function AuthShell({
|
||||
</Stack>
|
||||
|
||||
<Text fz="xs" style={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
© 2026 Ethiopian Maritime Authority
|
||||
{t('authShell.copyright', '© 2026 Ethiopian Maritime Authority')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useApiMutation } from "@ema-platform/api";
|
||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||
import { AuthShell } from "../components/AuthShell";
|
||||
@@ -34,40 +35,11 @@ import type {
|
||||
} from "../types/auth.types";
|
||||
import { useAuthConfig } from "../AuthConfig";
|
||||
import { authStorage } from "../utils/auth-storage";
|
||||
const emailOrPhone = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
// Convert 09xxxxxxxx -> +2519xxxxxxxx
|
||||
if (/^09\d{8}$/.test(value)) {
|
||||
return `+251${value.substring(1)}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRegex = /^\+2519\d{8}$/;
|
||||
|
||||
return emailRegex.test(value) || phoneRegex.test(value);
|
||||
},
|
||||
{
|
||||
message: "Enter a valid email or phone number (+2519xxxxxxxx)",
|
||||
},
|
||||
);
|
||||
const schema = z.object({
|
||||
email: emailOrPhone,
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters" }),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
|
||||
useAuthConfig();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -78,6 +50,41 @@ export function LoginPage() {
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||
|
||||
// Built inside the component (not module scope) so validation messages
|
||||
// pick up the active language — same pattern as ProfilePage's forms.
|
||||
const schema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
// Convert 09xxxxxxxx -> +2519xxxxxxxx
|
||||
if (/^09\d{8}$/.test(value)) {
|
||||
return `+251${value.substring(1)}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRegex = /^\+2519\d{8}$/;
|
||||
|
||||
return emailRegex.test(value) || phoneRegex.test(value);
|
||||
},
|
||||
{
|
||||
message: t(
|
||||
"login.emailOrPhoneInvalid",
|
||||
"Enter a valid email or phone number (+2519xxxxxxxx)",
|
||||
),
|
||||
},
|
||||
),
|
||||
password: z.string().min(8, {
|
||||
message: t("login.passwordMinLength", "Password must be at least 8 characters"),
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -144,10 +151,10 @@ export function LoginPage() {
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Welcome to {appName}
|
||||
{t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })}
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
Sign in to access your account.
|
||||
{t("login.subtitle", "Sign in to access your account.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -165,16 +172,16 @@ export function LoginPage() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or phone"
|
||||
placeholder="you@example.com"
|
||||
label={t("login.emailOrPhoneLabel", "Email or phone")}
|
||||
placeholder={t("login.emailOrPhonePlaceholder", "you@example.com")}
|
||||
size="md"
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
label={t("login.passwordLabel", "Password")}
|
||||
placeholder={t("login.passwordPlaceholder", "Your password")}
|
||||
size="md"
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.password?.message}
|
||||
@@ -183,7 +190,7 @@ export function LoginPage() {
|
||||
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
label={t("login.rememberMe", "Remember me")}
|
||||
size="sm"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
||||
@@ -195,7 +202,7 @@ export function LoginPage() {
|
||||
size="sm"
|
||||
fw={600}
|
||||
>
|
||||
Forgot password?
|
||||
{t("login.forgotPassword", "Forgot password?")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
@@ -207,15 +214,15 @@ export function LoginPage() {
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Sign in
|
||||
{t("login.signIn", "Sign in")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
{enableSignup && (
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Don't have an account?{" "}
|
||||
{t("login.noAccount", "Don't have an account? ")}
|
||||
<Anchor component={Link} to="/signup" fw={700}>
|
||||
Create one
|
||||
{t("login.createOne", "Create one")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
@@ -32,24 +33,6 @@ import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3, { message: 'Username must be at least 3 characters' }),
|
||||
phoneNumber: z.string().min(1, { message: 'Phone number is required' }),
|
||||
userType: z.literal('individual'),
|
||||
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
|
||||
nameAm: z.string().optional(),
|
||||
password: passwordSchema(8),
|
||||
confirmPassword: z.string().min(1, { message: 'Confirm your password' }),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface SignupPayload {
|
||||
email: string;
|
||||
username: string;
|
||||
@@ -66,6 +49,7 @@ interface SignupPayload {
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { appName, loginRedirectPath } = useAuthConfig();
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
@@ -77,6 +61,37 @@ export function SignupPage() {
|
||||
}>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
|
||||
// Order matches passwordRules' default list: length, lowercase, uppercase,
|
||||
// number, special character. Shared between the zod schema (field error)
|
||||
// and the live checklist below, so both agree on the wording.
|
||||
const passwordRuleLabels = [
|
||||
t('signup.passwordRule.minLength', { min: 8, defaultValue: 'At least {{min}} characters' }),
|
||||
t('signup.passwordRule.lowercase', 'One lowercase letter'),
|
||||
t('signup.passwordRule.uppercase', 'One uppercase letter'),
|
||||
t('signup.passwordRule.number', 'One number'),
|
||||
t('signup.passwordRule.special', 'One special character'),
|
||||
];
|
||||
|
||||
// Built inside the component (not module scope) so validation messages
|
||||
// pick up the active language — same pattern as ProfilePage's forms.
|
||||
const schema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
|
||||
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
|
||||
userType: z.literal('individual'),
|
||||
nameEn: z.string().min(1, { message: t('signup.nameEnRequired', 'Name (English) is required') }),
|
||||
nameAm: z.string().optional(),
|
||||
password: passwordSchema(8, passwordRuleLabels),
|
||||
confirmPassword: z.string().min(1, { message: t('signup.confirmPasswordRequired', 'Confirm your password') }),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t('signup.passwordsDontMatch', 'Passwords do not match'),
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -133,16 +148,19 @@ export function SignupPage() {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle={`Join ${appName}'s community.`}
|
||||
brandSubtitle={`Create your account to access ${appName} features.`}
|
||||
brandTitle={t('signup.brandTitle', { appName, defaultValue: "Join {{appName}}'s community." })}
|
||||
brandSubtitle={t('signup.brandSubtitle', {
|
||||
appName,
|
||||
defaultValue: 'Create your account to access {{appName}} features.',
|
||||
})}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Create account
|
||||
{t('signup.title', 'Create account')}
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
It only takes a minute to get started.
|
||||
{t('signup.subtitle', 'It only takes a minute to get started.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -156,15 +174,15 @@ export function SignupPage() {
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Name (English)"
|
||||
placeholder="Abebe Bekele"
|
||||
label={t('signup.nameEnLabel', 'Name (English)')}
|
||||
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameEn?.message}
|
||||
{...register('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name (Amharic)"
|
||||
placeholder="ስም"
|
||||
label={t('signup.nameAmLabel', 'Name (Amharic)')}
|
||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...register('nameAm')}
|
||||
@@ -173,15 +191,15 @@ export function SignupPage() {
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
label={t('signup.emailLabel', 'Email address')}
|
||||
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Choose a username"
|
||||
label={t('signup.usernameLabel', 'Username')}
|
||||
placeholder={t('signup.usernamePlaceholder', 'Choose a username')}
|
||||
leftSection={<IconAt size={18} />}
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
@@ -189,8 +207,8 @@ export function SignupPage() {
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
placeholder="+251 911 234 567"
|
||||
label={t('signup.phoneLabel', 'Phone number')}
|
||||
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...register('phoneNumber')}
|
||||
@@ -199,17 +217,21 @@ export function SignupPage() {
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="At least 8 characters"
|
||||
label={t('signup.passwordLabel', 'Password')}
|
||||
placeholder={t('signup.passwordPlaceholder', 'At least 8 characters')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
<PasswordRequirements password={watch('password') ?? ''} minLength={8} />
|
||||
<PasswordRequirements
|
||||
password={watch('password') ?? ''}
|
||||
minLength={8}
|
||||
labels={passwordRuleLabels}
|
||||
/>
|
||||
</div>
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
placeholder="Re-enter password"
|
||||
label={t('signup.confirmPasswordLabel', 'Confirm password')}
|
||||
placeholder={t('signup.confirmPasswordPlaceholder', 'Re-enter password')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
@@ -222,13 +244,13 @@ export function SignupPage() {
|
||||
onChange={(e) => setAgreed(e.currentTarget.checked)}
|
||||
label={
|
||||
<Text size="sm">
|
||||
I agree to the{' '}
|
||||
{t('signup.agreeToThe', 'I agree to the ')}
|
||||
<Anchor
|
||||
size="sm"
|
||||
fw={600}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
Terms & Privacy Policy
|
||||
{t('signup.termsAndPrivacy', 'Terms & Privacy Policy')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
}
|
||||
@@ -242,15 +264,15 @@ export function SignupPage() {
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Create account
|
||||
{t('signup.createAccount', 'Create account')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Already have an account?{' '}
|
||||
{t('signup.haveAccount', 'Already have an account? ')}
|
||||
<Anchor component={Link} to="/login" fw={700}>
|
||||
Sign in
|
||||
{t('signup.signIn', 'Sign in')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
718
libs/ui/src/lib/landing/LandingPage.tsx
Normal file
718
libs/ui/src/lib/landing/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
377
libs/ui/src/lib/landing/landing-copy.ts
Normal file
377
libs/ui/src/lib/landing/landing-copy.ts
Normal 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: 'ሁሉም መብቶች የተጠበቁ ናቸው።',
|
||||
},
|
||||
};
|
||||
59
libs/ui/src/lib/landing/landing.css
Normal file
59
libs/ui/src/lib/landing/landing.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
|
||||
@@ -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)';
|
||||
|
||||
Reference in New Issue
Block a user