mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: add internationalization support for auth components and pages, including translations for Amharic and English
This commit is contained in:
@@ -1283,4 +1283,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: "አንድ ልዩ ምልክት",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1279,6 +1279,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;
|
||||
|
||||
@@ -389,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: "አንድ ልዩ ምልክት",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -389,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user