diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 63d38a013..5fb97c9ff 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: "አንድ ልዩ ምልክት", + }, + }, }; diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 49a5a8b12..4630ccd16 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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; diff --git a/apps/backoffice/src/app/router/LandingRoute.tsx b/apps/backoffice/src/app/router/LandingRoute.tsx new file mode 100644 index 000000000..776bf01f8 --- /dev/null +++ b/apps/backoffice/src/app/router/LandingRoute.tsx @@ -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 ; +} diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index a5a2c5c5b..e04280c63 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -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: }, - { path: '/', element: }, + { path: '/', element: }, { path: '/profile-setup', element: }, { element: , @@ -69,7 +70,6 @@ const router = createBrowserRouter([ { element: , children: [ - { index: true, element: }, { path: 'dashboard', element: }, { path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], ) }, { path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, ) }, diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 5c3165aa8..5d991e0df 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -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', diff --git a/apps/portal/src/app/components/LandingRoute.tsx b/apps/portal/src/app/components/LandingRoute.tsx new file mode 100644 index 000000000..39f419253 --- /dev/null +++ b/apps/portal/src/app/components/LandingRoute.tsx @@ -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 ; +} diff --git a/apps/portal/src/app/features/verify/pages/VerifyCertificatePage.tsx b/apps/portal/src/app/features/verify/pages/VerifyCertificatePage.tsx deleted file mode 100644 index ed3ffd906..000000000 --- a/apps/portal/src/app/features/verify/pages/VerifyCertificatePage.tsx +++ /dev/null @@ -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 ( - - - - - EMA Certificate Verification - - - {!code && ( - - - - Scan the QR code on a certificate, or enter its verification - code below. - - - setManualCode(e.currentTarget.value)} - /> - - - - - )} - - {code && isLoading && ( -
- -
- )} - - {code && !isLoading && (isError || !data) && ( - - - Verification is temporarily unavailable. Please try again. - - - )} - - {code && data && ( - - - {data.valid ? ( - - -
- - Valid certificate - - - Issued by the Ethiopian Maritime Authority. - -
-
- ) : ( - - -
- - - Not valid - - {data.status && {data.status}} - - - {data.reason === 'not_found' - ? 'No certificate matches this code.' - : 'This certificate is no longer valid.'} - -
-
- )} - - {rows.length > 0 && ( - - - {rows - .filter(([, value]) => value) - .map(([label, value]) => ( - - {label} - {value} - - ))} - -
- )} - - -
-
- )} -
-
- ); -} diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 5de71b937..316757c79 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -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: "አንድ ልዩ ምልክት", + }, + }, }; diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index f05f83645..c5695efe3 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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; diff --git a/apps/portal/src/app/providers/AppProviders.tsx b/apps/portal/src/app/providers/AppProviders.tsx index f21fab6b8..886fd7138 100644 --- a/apps/portal/src/app/providers/AppProviders.tsx +++ b/apps/portal/src/app/providers/AppProviders.tsx @@ -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, }} > - - {children} - + + + {children} + + diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 957d3e19d..e07736e44 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -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: }, + // Public auth pages { path: "/login", element: }, { path: "/signup", element: }, - // Public certificate verification — the target of every printed QR code. - // No auth: a verifier scanning a certificate has no portal account. - { path: "/verify", element: }, - { path: "/verify/:code", element: }, - // 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: ( - - {/* Asks what the applicant operates as before the rest of the - portal, which is filtered by that answer. */} - - - - + {/* Asks what the applicant operates as before the rest of the + portal, which is filtered by that answer. */} + + + ), children: [ - { path: "/", element: }, { path: "/dashboard", element: }, { path: "/onboarding/operations", element: }, @@ -418,5 +411,7 @@ export const router = createBrowserRouter([ element: , }, - { path: "*", element: }, + // 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: }, ]); diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 07e2a4c54..485277781 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -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, @@ -769,7 +748,6 @@ export const licensingApi = baseApi }); export const { - useVerifyCertificateQuery, useGetLicenseTypesQuery, useGetLicenseCategoriesQuery, useUpdateLicenseFeesMutation, diff --git a/libs/auth/src/lib/components/AuthShell.tsx b/libs/auth/src/lib/components/AuthShell.tsx index f3e75ba54..da86df715 100644 --- a/libs/auth/src/lib/components/AuthShell.tsx +++ b/libs/auth/src/lib/components/AuthShell.tsx @@ -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 ( 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 ( - {brandTitle} + {resolvedBrandTitle} - {brandSubtitle} + {resolvedBrandSubtitle} @@ -204,7 +210,7 @@ export function AuthShell({ - © 2026 Ethiopian Maritime Authority + {t('authShell.copyright', '© 2026 Ethiopian Maritime Authority')} diff --git a/libs/auth/src/lib/pages/LoginPage.tsx b/libs/auth/src/lib/pages/LoginPage.tsx index 502a4b18f..09d4d4b60 100644 --- a/libs/auth/src/lib/pages/LoginPage.tsx +++ b/libs/auth/src/lib/pages/LoginPage.tsx @@ -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; 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(); 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; + const { register, handleSubmit, @@ -144,10 +151,10 @@ export function LoginPage() {
- Welcome to {appName} + {t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })} - Sign in to access your account. + {t("login.subtitle", "Sign in to access your account.")}
@@ -165,16 +172,16 @@ export function LoginPage() {
} error={errors.email?.message} {...register("email")} /> } error={errors.password?.message} @@ -183,7 +190,7 @@ export function LoginPage() { setRememberMe(e.currentTarget.checked)} @@ -195,7 +202,7 @@ export function LoginPage() { size="sm" fw={600} > - Forgot password? + {t("login.forgotPassword", "Forgot password?")} )} @@ -207,15 +214,15 @@ export function LoginPage() { size="md" rightSection={} > - Sign in + {t("login.signIn", "Sign in")}
{enableSignup && ( - Don't have an account?{" "} + {t("login.noAccount", "Don't have an account? ")} - Create one + {t("login.createOne", "Create one")} )} diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index 4ef7fb9d0..2ce426e1f 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -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; - 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(null); @@ -77,6 +61,37 @@ export function SignupPage() { }>(); const [meTrigger] = useApiMutation(); + // 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; + const { register, handleSubmit, @@ -133,16 +148,19 @@ export function SignupPage() { return (
- Create account + {t('signup.title', 'Create account')} - It only takes a minute to get started. + {t('signup.subtitle', 'It only takes a minute to get started.')}
@@ -156,15 +174,15 @@ export function SignupPage() { } error={errors.nameEn?.message} {...register('nameEn')} /> } error={errors.nameAm?.message} {...register('nameAm')} @@ -173,15 +191,15 @@ export function SignupPage() { } error={errors.email?.message} {...register('email')} /> } error={errors.username?.message} {...register('username')} @@ -189,8 +207,8 @@ export function SignupPage() { } error={errors.phoneNumber?.message} {...register('phoneNumber')} @@ -199,17 +217,21 @@ export function SignupPage() {
} error={errors.password?.message} {...register('password')} /> - +
} error={errors.confirmPassword?.message} {...register('confirmPassword')} @@ -222,13 +244,13 @@ export function SignupPage() { onChange={(e) => setAgreed(e.currentTarget.checked)} label={ - I agree to the{' '} + {t('signup.agreeToThe', 'I agree to the ')} e.preventDefault()} > - Terms & Privacy Policy + {t('signup.termsAndPrivacy', 'Terms & Privacy Policy')} } @@ -242,15 +264,15 @@ export function SignupPage() { size="md" rightSection={} > - Create account + {t('signup.createAccount', 'Create account')}
- Already have an account?{' '} + {t('signup.haveAccount', 'Already have an account? ')} - Sign in + {t('signup.signIn', 'Sign in')}
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index e713c8ee3..17f6e386a 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -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"; diff --git a/libs/ui/src/lib/input/PasswordRequirements.tsx b/libs/ui/src/lib/input/PasswordRequirements.tsx index ad9b812c7..c60f5e1c9 100644 --- a/libs/ui/src/lib/input/PasswordRequirements.tsx +++ b/libs/ui/src/lib/input/PasswordRequirements.tsx @@ -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 ( - {rules.map((rule, i) => { + {rules.map((rule) => { const met = rule.test(password); return ( @@ -47,7 +59,7 @@ export function PasswordRequirements({ password, minLength, labels }: PasswordRe )} - {labels?.[i] ?? rule.label} + {rule.label} ); diff --git a/libs/ui/src/lib/landing/LandingPage.tsx b/libs/ui/src/lib/landing/LandingPage.tsx new file mode 100644 index 000000000..333274b89 --- /dev/null +++ b/libs/ui/src/lib/landing/LandingPage.tsx @@ -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(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 ( + + { + e.currentTarget.style.top = '8px'; + }} + onBlur={(e) => { + e.currentTarget.style.top = '-60px'; + }} + > + {t('landing.meta.skipToContent')} + + +
+ +
+ + + + + + + + + +
+ +