From 476ec953b29f13850776d63b2e29ac3f35fa2002 Mon Sep 17 00:00:00 2001 From: Sajad Mirjalili Date: Thu, 21 Aug 2025 01:55:09 +0330 Subject: [PATCH] fix: validation states, responsive ui, ui states --- public/locales/en/completionForm.json | 9 +- public/locales/fa/completionForm.json | 9 +- .../components/AuthenticationCard.tsx | 5 +- .../UserCompletionForm/DateOfBirth.tsx | 9 +- .../UserCompletionForm/EmailSection.tsx | 47 ++-- .../UserCompletionForm/PasswordSection.tsx | 158 +++++------- .../UserCompletionForm/PersonalInfoFields.tsx | 231 ++++++++---------- .../UserCompletionForm/SubmitSection.tsx | 3 +- .../routes/UserCompletionPage.tsx | 101 ++++++-- .../authorization/types/settingForm.ts | 1 - 10 files changed, 312 insertions(+), 261 deletions(-) diff --git a/public/locales/en/completionForm.json b/public/locales/en/completionForm.json index 77b3ae3..ba8f78d 100644 --- a/public/locales/en/completionForm.json +++ b/public/locales/en/completionForm.json @@ -4,7 +4,7 @@ "description": "Enter your business information", "name": "Name", "familyName": "Family Name", - "gender": "Gender(Optional)", + "gender": "Gender", "optionalNationalCode": "National Code(Optional)", "determinePassword": "Determine Password", "password": "Password", @@ -19,7 +19,7 @@ "woman": "female", "hasNumber": "includes number", "hasMinLength": "at least 8 characters", - "hasUpperAndLower": "includes a lowercase and uppercase letter", + "hasUpperAndLower": "includes a lowercase and uppercase English letter", "hasSpecialChar": "includes sign (!@#$%^&*)", "notCompatibility": "does not match", "emailCorrectForm": "Enter the correct email form.", @@ -28,6 +28,7 @@ "agreementPart2": ".", "sent": "sent", "country": "country", + "noOption": "No option", "dateOfBirth": "Date of birth(optional)", "submitSuccess": "Information successfully registered", "submitError": "Error in registering information", @@ -46,6 +47,8 @@ "confirmPasswordRequired": "Please confirm your password.", "passwordsDoNotMatch": "Passwords do not match.", "verificationCodeRequired": "Verification code is required.", - "mustVerifyCode": "Please click the verify button to confirm the code." + "mustVerifyCode": "Please click the verify button to confirm the code.", + "genderRequired": "Selecting a gender is required.", + "verificationCodeInvalid": "Verification code must be 4 digits." } } diff --git a/public/locales/fa/completionForm.json b/public/locales/fa/completionForm.json index c8f12c9..bb609a8 100644 --- a/public/locales/fa/completionForm.json +++ b/public/locales/fa/completionForm.json @@ -4,7 +4,7 @@ "description": "اطلاعات کسب و کار خود را وارد کنید", "name": "نام", "familyName": "نام خانوادگی", - "gender": "جنسیت(اختیاری)", + "gender": "جنسیت", "optionalNationalCode": "کدملی(اختیاری)", "determinePassword": "تعیین رمز عبور", "password": "رمز عبور", @@ -19,7 +19,7 @@ "woman": "زن", "hasNumber": "شامل عدد", "hasMinLength": "حداقل 8 کاراکتر", - "hasUpperAndLower": "شامل یک حرف کوچک و بزرگ", + "hasUpperAndLower": "شامل یک حرف کوچک و بزرگ انگلیسی", "hasSpecialChar": "شامل علامت (!@#$%^&*)", "notCompatibility": "تکرار رمز عبور با رمز عبور یکسان نمی باشد", "emailCorrectForm": "ساختار ایمیل صحیح نیست", @@ -28,6 +28,7 @@ "agreementPart2": " می باشد.", "sent": "ارسال شد!", "country": "کشور", + "noOption": "گزینه ای یافت نشد", "dateOfBirth": "تاریخ تولد(اختیاری)", "invalidCountry": "کشور انتخاب شده صحیح نیست", "rules": "قوانین و مقررات", @@ -54,6 +55,8 @@ "confirmPasswordRequired": "لطفاً رمز عبور خود را تکرار کنید.", "passwordsDoNotMatch": "رمزهای عبور با یکدیگر مطابقت ندارند.", "verificationCodeRequired": "وارد کردن کد تایید الزامی است.", - "mustVerifyCode": "لطفاً برای تایید کد، روی دکمه بررسی کلیک کنید." + "mustVerifyCode": "لطفاً برای تایید کد، روی دکمه بررسی کلیک کنید.", + "genderRequired": "انتخاب جنسیت الزامی است.", + "verificationCodeInvalid": "کد تایید باید ۴ رقم باشد." } } diff --git a/src/features/authorization/components/AuthenticationCard.tsx b/src/features/authorization/components/AuthenticationCard.tsx index 444b83e..7265387 100644 --- a/src/features/authorization/components/AuthenticationCard.tsx +++ b/src/features/authorization/components/AuthenticationCard.tsx @@ -18,11 +18,12 @@ export const AuthenticationCard = ({ sx={{ borderRadius: 2, p: { - xs: 4, + xs: 3, md: 6, }, marginInline: 2, - width: (t) => `calc(100% - ${t.spacing(2)})`, + boxSizing: 'border-box', + width: '100%', maxWidth: maxWidth ?? '552px', ...sx, diff --git a/src/features/authorization/components/UserCompletionForm/DateOfBirth.tsx b/src/features/authorization/components/UserCompletionForm/DateOfBirth.tsx index ecf6d99..bb803ac 100644 --- a/src/features/authorization/components/UserCompletionForm/DateOfBirth.tsx +++ b/src/features/authorization/components/UserCompletionForm/DateOfBirth.tsx @@ -15,6 +15,8 @@ import { format } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { toLocaleDigits } from '@/utils/persianDigit'; import { type DateOfBirthProps } from '../../types/settingForm'; +import { Icon } from '@rkheftan/harmony-ui'; +import { Calendar } from 'iconsax-react'; export default function DateOfBirth({ value, onChange }: DateOfBirthProps) { const { t, i18n } = useTranslation('completionForm'); @@ -49,6 +51,10 @@ export default function DateOfBirth({ value, onChange }: DateOfBirthProps) { ); }; + const CustomCalendarIcon = () => ( + + ); + return ( , + ) => { + const value = e.target.value; + // Allow only digits and enforce the max length + if (isNumeric(value) && value.length <= 4) { + setVerificationCode(value); + } + }; + const formatTimerValue = () => { const m = String(Math.floor(countdown / 60)).padStart(2, '0'); const s = String(countdown % 60).padStart(2, '0'); @@ -71,7 +82,6 @@ export function EmailSection({ display: 'flex', flexDirection: 'column', gap: 2, - px: { xs: 2, sm: 0 }, }} > setEmail(e.target.value)} onBlur={() => handleBlur('email')} + disabled={isSendingCode || codeSent} error={touched.email && !!errors.email} helperText={touched.email && errors.email} sx={{ flex: '1 1 260px' }} @@ -97,7 +108,7 @@ export function EmailSection({ input: { startAdornment: !isVerifyingCode && emailVerified ? ( - + ) : null, - endAdornment: - buttonState === 'counting' ? ( - - - - - - ) : null, - sx: { - paddingLeft: buttonState === 'counting' ? 0 : undefined, - }, + endAdornment: codeSent ? ( + + + + + + ) : null, }, }} /> @@ -164,9 +171,11 @@ export function EmailSection({ > setVerificationCode(e.target.value)} + onChange={handleVerificationCodeChange} sx={{ flex: '1 1 260px' }} disabled={isVerifyingCode} onBlur={() => handleBlur('verificationCode')} diff --git a/src/features/authorization/components/UserCompletionForm/PasswordSection.tsx b/src/features/authorization/components/UserCompletionForm/PasswordSection.tsx index 9254980..c3b4518 100644 --- a/src/features/authorization/components/UserCompletionForm/PasswordSection.tsx +++ b/src/features/authorization/components/UserCompletionForm/PasswordSection.tsx @@ -7,6 +7,7 @@ import { FormGroup, Typography, InputAdornment, + Grid, } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { TickCircle, Eye, EyeSlash, CloseCircle } from 'iconsax-react'; @@ -27,7 +28,6 @@ export function PasswordSection({ hasUpperAndLower, hasSpecialChar, validPassword, - showValidations, errors, touched, handleBlur, @@ -36,6 +36,7 @@ export function PasswordSection({ const [showPasswordText, setShowPasswordText] = useState(false); const [showPasswordRepetitionText, setShowPasswordRepetitionText] = useState(false); + const [showValidations, setShowValidation] = useState(false); const handleTogglePasswordSection = ( e: React.ChangeEvent, @@ -47,6 +48,11 @@ export function PasswordSection({ const handleTogglePasswordRepetitionEye = () => setShowPasswordRepetitionText((prev) => !prev); + const handleBlurPassword = () => { + handleBlur('password'); + setShowValidation(false); + }; + return ( <> @@ -72,29 +78,18 @@ export function PasswordSection({ {showPasswordSection && ( - - + + setPassword(e.target.value)} variant="outlined" type={showPasswordText ? 'text' : 'password'} autoFocus - onBlur={() => handleBlur('password')} + onBlur={handleBlurPassword} + onFocus={() => setShowValidation(true)} error={touched.password && !!errors.password} helperText={touched.password && errors.password} sx={{ @@ -103,18 +98,10 @@ export function PasswordSection({ pr: 8, // Increased padding to accommodate both icons }, }} - // FIXME: deprecated - InputProps={{ - endAdornment: ( - - + slotProps={{ + input: { + endAdornment: ( + )} - {validPassword && ( - - )} - - - ), + + ), + startAdornment: validPassword && ( + + + + ), + }, }} /> - + + - + slotProps={{ + input: { + endAdornment: ( + )} - {confirmPassword.length > 0 && ( - - )} - - - ), + + ), + startAdornment: confirmPassword.length > 0 && ( + + + + ), + }, }} /> - + - {password && showValidations && ( - - + {showValidations && ( + <> + + + + - - + + + + + + - - + + )} - + )} ); diff --git a/src/features/authorization/components/UserCompletionForm/PersonalInfoFields.tsx b/src/features/authorization/components/UserCompletionForm/PersonalInfoFields.tsx index 0fc74b1..555ad56 100644 --- a/src/features/authorization/components/UserCompletionForm/PersonalInfoFields.tsx +++ b/src/features/authorization/components/UserCompletionForm/PersonalInfoFields.tsx @@ -6,7 +6,9 @@ import { Select, Box, Autocomplete, + Grid, type SelectChangeEvent, + FormHelperText, } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Woman, Man } from 'iconsax-react'; @@ -63,134 +65,115 @@ export function PersonalInfoFields({ ]; return ( - - - - setFirstName(e.target.value)} - sx={{ flex: '1 1 260px' }} - onBlur={() => handleBlur('firstName')} - error={touched.firstName && !!errors.firstName} - helperText={touched.firstName && errors.firstName} - /> - setLastName(e.target.value)} - sx={{ flex: '1 1 260px' }} - onBlur={() => handleBlur('lastName')} - error={touched.lastName && !!errors.lastName} - helperText={touched.lastName && errors.lastName} - /> - + + + setFirstName(e.target.value)} + onBlur={() => handleBlur('firstName')} + error={touched.firstName && !!errors.firstName} + helperText={touched.firstName && errors.firstName} + /> + - - - {t('completion.gender')} - - + + setLastName(e.target.value)} + onBlur={() => handleBlur('lastName')} + error={touched.lastName && !!errors.lastName} + helperText={touched.lastName && errors.lastName} + /> + - setNationalId(e.target.value)} - variant="outlined" - sx={{ flex: '1 1 260px' }} - onBlur={() => handleBlur('nationalId')} - error={touched.nationalId && !!errors.nationalId} - helperText={touched.nationalId && errors.nationalId} - /> - + + + {t('completion.gender')} + - - option.label} - value={currentCountry} - onChange={(_, newValue) => setCountry(newValue?.code || '')} - onBlur={() => handleBlur('country')} - renderOption={(props, option) => ( - - - {option.label} - - )} - renderInput={(params) => ( - {errors.sex} + )} + + + + + option.label} + noOptionsText={t('completion.noOption')} + value={currentCountry} + onChange={(_, newValue) => setCountry(newValue?.code || '')} + onBlur={() => handleBlur('country')} + renderOption={(props, option) => ( + + - )} - clearOnEscape - /> + {option.label} + + )} + renderInput={(params) => ( + + )} + clearOnEscape + /> + - - - - - - + + setNationalId(e.target.value)} + variant="outlined" + onBlur={() => handleBlur('nationalId')} + error={touched.nationalId && !!errors.nationalId} + helperText={touched.nationalId && errors.nationalId} + /> + + + + + + ); } diff --git a/src/features/authorization/components/UserCompletionForm/SubmitSection.tsx b/src/features/authorization/components/UserCompletionForm/SubmitSection.tsx index 982a40a..0e97f19 100644 --- a/src/features/authorization/components/UserCompletionForm/SubmitSection.tsx +++ b/src/features/authorization/components/UserCompletionForm/SubmitSection.tsx @@ -28,9 +28,8 @@ export function SubmitSection({ onSubmit, loading }: SubmitProps) { display: 'flex', flexWrap: 'wrap', gap: 2, - px: { xs: 2, sm: 0 }, mb: 2, - justifyContent: { xs: 'center', sm: 'flex-start' }, + justifyContent: 'flex-start', }} > ({}); const [touched, setTouched] = useState<{ [key: string]: boolean }>({}); @@ -73,10 +72,6 @@ export function UserCompletionPage() { completeUserInformationApi, ); - useEffect(() => { - setShowPasswordValidations(password ? !validPassword : false); - }, [password, validPassword]); - useEffect(() => { let timer: NodeJS.Timeout; if (buttonState === 'counting' && countdown > 0) { @@ -101,6 +96,17 @@ export function UserCompletionPage() { return; } + setTouched((prev) => { + const newTouched = { ...prev }; + delete newTouched.verificationCode; + return newTouched; + }); + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.verificationCode; + return newErrors; + }); + const res = await sendCode({ email }); if (res) { @@ -122,14 +128,23 @@ export function UserCompletionPage() { }; const handleVerifyCode = async () => { - if (!verificationCode.trim()) { - // Manually trigger the validation error and stop + const trimmedCode = verificationCode.trim(); + + // Manually trigger the validation error and stop + if (!trimmedCode) { setTouched((prev) => ({ ...prev, verificationCode: true })); setErrors((prev) => ({ ...prev, verificationCode: t('validation.verificationCodeRequired'), })); return; + } else if (trimmedCode.length < 4) { + setTouched((prev) => ({ ...prev, verificationCode: true })); + setErrors((prev) => ({ + ...prev, + verificationCode: t('validation.verificationCodeInvalid'), + })); + return; } const res = await verifyCode({ email, otpCode: verificationCode }); @@ -161,6 +176,7 @@ export function UserCompletionPage() { verificationCode: showEmail, password: showPasswordSection, confirmPassword: showPasswordSection, + sex: true, }); const isValid = validateForm(); @@ -207,10 +223,22 @@ export function UserCompletionPage() { setCodeSent(false); setEmailVerified(false); setVerificationCode(''); + // We clear both touched and errors + setTouched((prev) => { + const newTouched = { ...prev }; + delete newTouched.email; + delete newTouched.verificationCode; + return newTouched; + }); + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.email; + delete newErrors.verificationCode; + return newErrors; + }); }; const validateForm = () => { - // TODO: check if need separate validation or same common validation const newErrors: { [key: string]: string } = {}; // Rule 1: First Name is required @@ -235,11 +263,16 @@ export function UserCompletionPage() { // Rule 6: If verification code sent and email section is active if (showEmail && codeSent && !emailVerified) { - if (!verificationCode.trim()) { + const trimmedCode = verificationCode.trim(); + + if (!trimmedCode) { // Case 1: The code is required but the field is empty. newErrors.verificationCode = t('validation.verificationCodeRequired'); + } else if (trimmedCode.length < 4) { + // Case 2 : The code is entered but is less than 4 digits. + newErrors.verificationCode = t('validation.verificationCodeInvalid'); } else { - // Case 2: The user has typed a code but hasn't clicked "Verify" yet. + // Case 3: The user has typed a code but hasn't clicked "Verify" yet. newErrors.verificationCode = t('validation.mustVerifyCode'); } } @@ -263,6 +296,10 @@ export function UserCompletionPage() { } } + if (sex === null) { + newErrors.sex = t('validation.genderRequired'); + } + setErrors(newErrors); return Object.keys(newErrors).length === 0; // Returns true if form is valid }; @@ -271,6 +308,43 @@ export function UserCompletionPage() { setTouched((prev) => ({ ...prev, [field]: true })); }; + useEffect(() => { + if (!showPasswordSection) { + // We clear both touched and errors to prevent lingering validation messages + setTouched((prev) => { + const newTouched = { ...prev }; + delete newTouched.password; + delete newTouched.confirmPassword; + return newTouched; + }); + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.password; + delete newErrors.confirmPassword; + return newErrors; + }); + } + }, [showPasswordSection]); + + // This effect resets email fields when the section is hidden + useEffect(() => { + if (!showEmail) { + // We clear both touched and errors + setTouched((prev) => { + const newTouched = { ...prev }; + delete newTouched.email; + delete newTouched.verificationCode; + return newTouched; + }); + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.email; + delete newErrors.verificationCode; + return newErrors; + }); + } + }, [showEmail]); + // re-validate whenever a field the user has touched changes value useEffect(() => { // Only run validation if at least one field has been touched @@ -289,6 +363,7 @@ export function UserCompletionPage() { password, confirmPassword, showPasswordSection, + sex, touched, ]); @@ -300,17 +375,16 @@ export function UserCompletionPage() { sx={{ minHeight: '100vh', gap: 3, - // TODO: check if this padding needed for mobile view - py: { sm: 0, xs: 2 }, + py: { md: 0, xs: 4 }, }} > @@ -361,7 +435,6 @@ export function UserCompletionPage() { hasUpperAndLower={hasUpperAndLower} hasSpecialChar={hasSpecialChar} validPassword={validPassword} - showValidations={showPasswordValidations} errors={errors} touched={touched} handleBlur={handleBlur} diff --git a/src/features/authorization/types/settingForm.ts b/src/features/authorization/types/settingForm.ts index 5fa7bc8..68d7030 100644 --- a/src/features/authorization/types/settingForm.ts +++ b/src/features/authorization/types/settingForm.ts @@ -49,7 +49,6 @@ export interface PasswordSectionProps extends ValidationProps { hasUpperAndLower: boolean; hasSpecialChar: boolean; validPassword: boolean; - showValidations: boolean; } export interface ValidationItemProps {