From e0b3a4887a253ab281948918ab06d611dd223118 Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 14 Aug 2026 07:54:30 +0000 Subject: [PATCH] feat: add internationalization support for auth components and pages, including translations for Amharic and English --- apps/backoffice/src/app/i18n/locales/am.ts | 64 +++++++++++ apps/backoffice/src/app/i18n/locales/en.ts | 64 +++++++++++ apps/portal/src/app/i18n/locales/am.ts | 64 +++++++++++ apps/portal/src/app/i18n/locales/en.ts | 64 +++++++++++ libs/auth/src/lib/components/AuthShell.tsx | 36 +++--- libs/auth/src/lib/pages/LoginPage.tsx | 89 ++++++++------- libs/auth/src/lib/pages/SignupPage.tsx | 106 +++++++++++------- .../ui/src/lib/input/PasswordRequirements.tsx | 34 ++++-- 8 files changed, 412 insertions(+), 109 deletions(-) diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index da2e6234b..0ea5d86d0 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: "አንድ ልዩ ምልክት", + }, + }, }; diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 9b51214c2..cda11d8a8 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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; diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 4a0bea10f..316757c79 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -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: "አንድ ልዩ ምልክት", + }, + }, }; diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 175d3918e..c5695efe3 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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; 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/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} );