fix: validation states, responsive ui, ui states

This commit is contained in:
Sajad Mirjalili
2025-08-21 01:55:09 +03:30
parent b02f655d4d
commit 476ec953b2
10 changed files with 312 additions and 261 deletions

View File

@@ -4,7 +4,7 @@
"description": "Enter your business information", "description": "Enter your business information",
"name": "Name", "name": "Name",
"familyName": "Family Name", "familyName": "Family Name",
"gender": "Gender(Optional)", "gender": "Gender",
"optionalNationalCode": "National Code(Optional)", "optionalNationalCode": "National Code(Optional)",
"determinePassword": "Determine Password", "determinePassword": "Determine Password",
"password": "Password", "password": "Password",
@@ -19,7 +19,7 @@
"woman": "female", "woman": "female",
"hasNumber": "includes number", "hasNumber": "includes number",
"hasMinLength": "at least 8 characters", "hasMinLength": "at least 8 characters",
"hasUpperAndLower": "includes a lowercase and uppercase letter", "hasUpperAndLower": "includes a lowercase and uppercase English letter",
"hasSpecialChar": "includes sign (!@#$%^&*)", "hasSpecialChar": "includes sign (!@#$%^&*)",
"notCompatibility": "does not match", "notCompatibility": "does not match",
"emailCorrectForm": "Enter the correct email form.", "emailCorrectForm": "Enter the correct email form.",
@@ -28,6 +28,7 @@
"agreementPart2": ".", "agreementPart2": ".",
"sent": "sent", "sent": "sent",
"country": "country", "country": "country",
"noOption": "No option",
"dateOfBirth": "Date of birth(optional)", "dateOfBirth": "Date of birth(optional)",
"submitSuccess": "Information successfully registered", "submitSuccess": "Information successfully registered",
"submitError": "Error in registering information", "submitError": "Error in registering information",
@@ -46,6 +47,8 @@
"confirmPasswordRequired": "Please confirm your password.", "confirmPasswordRequired": "Please confirm your password.",
"passwordsDoNotMatch": "Passwords do not match.", "passwordsDoNotMatch": "Passwords do not match.",
"verificationCodeRequired": "Verification code is required.", "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."
} }
} }

View File

@@ -4,7 +4,7 @@
"description": "اطلاعات کسب و کار خود را وارد کنید", "description": "اطلاعات کسب و کار خود را وارد کنید",
"name": "نام", "name": "نام",
"familyName": "نام خانوادگی", "familyName": "نام خانوادگی",
"gender": "جنسیت(اختیاری)", "gender": "جنسیت",
"optionalNationalCode": "کدملی(اختیاری)", "optionalNationalCode": "کدملی(اختیاری)",
"determinePassword": "تعیین رمز عبور", "determinePassword": "تعیین رمز عبور",
"password": "رمز عبور", "password": "رمز عبور",
@@ -19,7 +19,7 @@
"woman": "زن", "woman": "زن",
"hasNumber": "شامل عدد", "hasNumber": "شامل عدد",
"hasMinLength": "حداقل 8 کاراکتر", "hasMinLength": "حداقل 8 کاراکتر",
"hasUpperAndLower": "شامل یک حرف کوچک و بزرگ", "hasUpperAndLower": "شامل یک حرف کوچک و بزرگ انگلیسی",
"hasSpecialChar": "شامل علامت (!@#$%^&*)", "hasSpecialChar": "شامل علامت (!@#$%^&*)",
"notCompatibility": "تکرار رمز عبور با رمز عبور یکسان نمی باشد", "notCompatibility": "تکرار رمز عبور با رمز عبور یکسان نمی باشد",
"emailCorrectForm": "ساختار ایمیل صحیح نیست", "emailCorrectForm": "ساختار ایمیل صحیح نیست",
@@ -28,6 +28,7 @@
"agreementPart2": " می باشد.", "agreementPart2": " می باشد.",
"sent": "ارسال شد!", "sent": "ارسال شد!",
"country": "کشور", "country": "کشور",
"noOption": "گزینه ای یافت نشد",
"dateOfBirth": "تاریخ تولد(اختیاری)", "dateOfBirth": "تاریخ تولد(اختیاری)",
"invalidCountry": "کشور انتخاب شده صحیح نیست", "invalidCountry": "کشور انتخاب شده صحیح نیست",
"rules": "قوانین و مقررات", "rules": "قوانین و مقررات",
@@ -54,6 +55,8 @@
"confirmPasswordRequired": "لطفاً رمز عبور خود را تکرار کنید.", "confirmPasswordRequired": "لطفاً رمز عبور خود را تکرار کنید.",
"passwordsDoNotMatch": "رمزهای عبور با یکدیگر مطابقت ندارند.", "passwordsDoNotMatch": "رمزهای عبور با یکدیگر مطابقت ندارند.",
"verificationCodeRequired": "وارد کردن کد تایید الزامی است.", "verificationCodeRequired": "وارد کردن کد تایید الزامی است.",
"mustVerifyCode": "لطفاً برای تایید کد، روی دکمه بررسی کلیک کنید." "mustVerifyCode": "لطفاً برای تایید کد، روی دکمه بررسی کلیک کنید.",
"genderRequired": "انتخاب جنسیت الزامی است.",
"verificationCodeInvalid": "کد تایید باید ۴ رقم باشد."
} }
} }

View File

@@ -18,11 +18,12 @@ export const AuthenticationCard = ({
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
p: { p: {
xs: 4, xs: 3,
md: 6, md: 6,
}, },
marginInline: 2, marginInline: 2,
width: (t) => `calc(100% - ${t.spacing(2)})`, boxSizing: 'border-box',
width: '100%',
maxWidth: maxWidth ?? '552px', maxWidth: maxWidth ?? '552px',
...sx, ...sx,

View File

@@ -15,6 +15,8 @@ import { format } from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toLocaleDigits } from '@/utils/persianDigit'; import { toLocaleDigits } from '@/utils/persianDigit';
import { type DateOfBirthProps } from '../../types/settingForm'; import { type DateOfBirthProps } from '../../types/settingForm';
import { Icon } from '@rkheftan/harmony-ui';
import { Calendar } from 'iconsax-react';
export default function DateOfBirth({ value, onChange }: DateOfBirthProps) { export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
const { t, i18n } = useTranslation('completionForm'); const { t, i18n } = useTranslation('completionForm');
@@ -49,6 +51,10 @@ export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
); );
}; };
const CustomCalendarIcon = () => (
<Icon Component={Calendar} color="primary.main" variant="Bold" />
);
return ( return (
<LocalizationProvider dateAdapter={Adapter} adapterLocale={locale}> <LocalizationProvider dateAdapter={Adapter} adapterLocale={locale}>
<DatePicker <DatePicker
@@ -57,7 +63,8 @@ export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
onChange={onChange} onChange={onChange}
format={formatString} format={formatString}
dayOfWeekFormatter={dayOfWeekFormatter} dayOfWeekFormatter={dayOfWeekFormatter}
slots={{ day: CustomDay }} slots={{ day: CustomDay, openPickerIcon: CustomCalendarIcon }}
disableFuture
slotProps={{ slotProps={{
textField: { textField: {
fullWidth: true, fullWidth: true,

View File

@@ -10,10 +10,11 @@ import {
IconButton, IconButton,
} from '@mui/material'; } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TickCircle, Edit } from 'iconsax-react'; import { TickCircle, Edit2 } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { type EmailSectionProps } from '../../types/settingForm'; import { type EmailSectionProps } from '../../types/settingForm';
import { toLocaleDigits } from '@/utils/persianDigit'; import { toLocaleDigits } from '@/utils/persianDigit';
import { isNumeric } from '@/utils/regexes/isNumeric';
export function EmailSection({ export function EmailSection({
showEmail, showEmail,
@@ -41,6 +42,16 @@ export function EmailSection({
setShowEmail(e.target.checked); setShowEmail(e.target.checked);
}; };
const handleVerificationCodeChange = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const value = e.target.value;
// Allow only digits and enforce the max length
if (isNumeric(value) && value.length <= 4) {
setVerificationCode(value);
}
};
const formatTimerValue = () => { const formatTimerValue = () => {
const m = String(Math.floor(countdown / 60)).padStart(2, '0'); const m = String(Math.floor(countdown / 60)).padStart(2, '0');
const s = String(countdown % 60).padStart(2, '0'); const s = String(countdown % 60).padStart(2, '0');
@@ -71,7 +82,6 @@ export function EmailSection({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 2, gap: 2,
px: { xs: 2, sm: 0 },
}} }}
> >
<Box <Box
@@ -90,6 +100,7 @@ export function EmailSection({
autoFocus autoFocus
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
onBlur={() => handleBlur('email')} onBlur={() => handleBlur('email')}
disabled={isSendingCode || codeSent}
error={touched.email && !!errors.email} error={touched.email && !!errors.email}
helperText={touched.email && errors.email} helperText={touched.email && errors.email}
sx={{ flex: '1 1 260px' }} sx={{ flex: '1 1 260px' }}
@@ -97,7 +108,7 @@ export function EmailSection({
input: { input: {
startAdornment: startAdornment:
!isVerifyingCode && emailVerified ? ( !isVerifyingCode && emailVerified ? (
<InputAdornment position="end"> <InputAdornment position="start">
<Icon <Icon
Component={TickCircle} Component={TickCircle}
size="medium" size="medium"
@@ -106,21 +117,17 @@ export function EmailSection({
/> />
</InputAdornment> </InputAdornment>
) : null, ) : null,
endAdornment: endAdornment: codeSent ? (
buttonState === 'counting' ? ( <InputAdornment position="end">
<InputAdornment position="start">
<IconButton onClick={handleEditEmail}> <IconButton onClick={handleEditEmail}>
<Icon <Icon
Component={Edit} Component={Edit2}
color="primary.main" color="primary.main"
size="medium" size="medium"
/> />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) : null, ) : null,
sx: {
paddingLeft: buttonState === 'counting' ? 0 : undefined,
},
}, },
}} }}
/> />
@@ -164,9 +171,11 @@ export function EmailSection({
> >
<TextField <TextField
label={t('completion.verificationCode')} label={t('completion.verificationCode')}
autoFocus
variant="outlined" variant="outlined"
type="text"
value={verificationCode} value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)} onChange={handleVerificationCodeChange}
sx={{ flex: '1 1 260px' }} sx={{ flex: '1 1 260px' }}
disabled={isVerifyingCode} disabled={isVerifyingCode}
onBlur={() => handleBlur('verificationCode')} onBlur={() => handleBlur('verificationCode')}

View File

@@ -7,6 +7,7 @@ import {
FormGroup, FormGroup,
Typography, Typography,
InputAdornment, InputAdornment,
Grid,
} from '@mui/material'; } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TickCircle, Eye, EyeSlash, CloseCircle } from 'iconsax-react'; import { TickCircle, Eye, EyeSlash, CloseCircle } from 'iconsax-react';
@@ -27,7 +28,6 @@ export function PasswordSection({
hasUpperAndLower, hasUpperAndLower,
hasSpecialChar, hasSpecialChar,
validPassword, validPassword,
showValidations,
errors, errors,
touched, touched,
handleBlur, handleBlur,
@@ -36,6 +36,7 @@ export function PasswordSection({
const [showPasswordText, setShowPasswordText] = useState(false); const [showPasswordText, setShowPasswordText] = useState(false);
const [showPasswordRepetitionText, setShowPasswordRepetitionText] = const [showPasswordRepetitionText, setShowPasswordRepetitionText] =
useState(false); useState(false);
const [showValidations, setShowValidation] = useState(false);
const handleTogglePasswordSection = ( const handleTogglePasswordSection = (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
@@ -47,6 +48,11 @@ export function PasswordSection({
const handleTogglePasswordRepetitionEye = () => const handleTogglePasswordRepetitionEye = () =>
setShowPasswordRepetitionText((prev) => !prev); setShowPasswordRepetitionText((prev) => !prev);
const handleBlurPassword = () => {
handleBlur('password');
setShowValidation(false);
};
return ( return (
<> <>
<FormGroup> <FormGroup>
@@ -72,29 +78,18 @@ export function PasswordSection({
</FormGroup> </FormGroup>
{showPasswordSection && ( {showPasswordSection && (
<Box <Grid container spacing={2}>
sx={{ <Grid size={{ xs: 12, md: 6 }}>
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField <TextField
fullWidth
label={t('completion.password')} label={t('completion.password')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
variant="outlined" variant="outlined"
type={showPasswordText ? 'text' : 'password'} type={showPasswordText ? 'text' : 'password'}
autoFocus autoFocus
onBlur={() => handleBlur('password')} onBlur={handleBlurPassword}
onFocus={() => setShowValidation(true)}
error={touched.password && !!errors.password} error={touched.password && !!errors.password}
helperText={touched.password && errors.password} helperText={touched.password && errors.password}
sx={{ sx={{
@@ -103,18 +98,10 @@ export function PasswordSection({
pr: 8, // Increased padding to accommodate both icons pr: 8, // Increased padding to accommodate both icons
}, },
}} }}
// FIXME: deprecated slotProps={{
InputProps={{ input: {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minWidth: '64px', // Adjusted to fit both icons
}}
>
<IconButton <IconButton
onClick={handleTogglePasswordEye} onClick={handleTogglePasswordEye}
sx={{ p: 0.5 }} sx={{ p: 0.5 }}
@@ -133,21 +120,25 @@ export function PasswordSection({
/> />
)} )}
</IconButton> </IconButton>
{validPassword && ( </InputAdornment>
),
startAdornment: validPassword && (
<InputAdornment position="start">
<Icon <Icon
Component={TickCircle} Component={TickCircle}
size="medium" size="medium"
color="success.main" color="success.main"
variant="Bold" variant="Bold"
/> />
)}
</Box>
</InputAdornment> </InputAdornment>
), ),
},
}} }}
/> />
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth
label={t('completion.passwordRepetition')} label={t('completion.passwordRepetition')}
variant="outlined" variant="outlined"
value={confirmPassword} value={confirmPassword}
@@ -157,19 +148,12 @@ export function PasswordSection({
helperText={touched.confirmPassword && errors.confirmPassword} helperText={touched.confirmPassword && errors.confirmPassword}
type={showPasswordRepetitionText ? 'text' : 'password'} type={showPasswordRepetitionText ? 'text' : 'password'}
sx={{ sx={{
flex: '1 1 260px', flex: '1 1',
}} }}
InputProps={{ slotProps={{
input: {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minWidth: '64px',
}}
>
<IconButton <IconButton
onClick={handleTogglePasswordRepetitionEye} onClick={handleTogglePasswordRepetitionEye}
sx={{ p: 0.5 }} sx={{ p: 0.5 }}
@@ -188,65 +172,55 @@ export function PasswordSection({
/> />
)} )}
</IconButton> </IconButton>
{confirmPassword.length > 0 && ( </InputAdornment>
),
startAdornment: confirmPassword.length > 0 && (
<InputAdornment position="start">
<Icon <Icon
Component={matchPassword ? TickCircle : CloseCircle} Component={matchPassword ? TickCircle : CloseCircle}
size="medium" size="medium"
color={matchPassword ? 'success.main' : 'error.main'} color={matchPassword ? 'success.main' : 'error.main'}
variant="Bold" variant="Bold"
/> />
)}
</Box>
</InputAdornment> </InputAdornment>
), ),
},
}} }}
/> />
</Box> </Grid>
{password && showValidations && ( {showValidations && (
<Box <>
sx={{ <Grid size={{ xs: 12, md: 6 }}>
display: 'flex',
flexWrap: 'wrap',
gap: { xs: 0, sm: 2 },
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Box
sx={{
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
}}
>
<PasswordValidationItem <PasswordValidationItem
isValid={hasNumber} isValid={hasNumber}
label={t('completion.hasNumber')} label={t('completion.hasNumber')}
/> />
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<PasswordValidationItem <PasswordValidationItem
isValid={hasMinLength} isValid={hasMinLength}
label={t('completion.hasMinLength')} label={t('completion.hasMinLength')}
/> />
</Box> </Grid>
<Box
sx={{ <Grid size={{ xs: 12, md: 6 }}>
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
}}
>
<PasswordValidationItem <PasswordValidationItem
isValid={hasUpperAndLower} isValid={hasUpperAndLower}
label={t('completion.hasUpperAndLower')} label={t('completion.hasUpperAndLower')}
/> />
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<PasswordValidationItem <PasswordValidationItem
isValid={hasSpecialChar} isValid={hasSpecialChar}
label={t('completion.hasSpecialChar')} label={t('completion.hasSpecialChar')}
/> />
</Box> </Grid>
</Box> </>
)} )}
</Box> </Grid>
)} )}
</> </>
); );

View File

@@ -6,7 +6,9 @@ import {
Select, Select,
Box, Box,
Autocomplete, Autocomplete,
Grid,
type SelectChangeEvent, type SelectChangeEvent,
FormHelperText,
} from '@mui/material'; } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Woman, Man } from 'iconsax-react'; import { Woman, Man } from 'iconsax-react';
@@ -63,61 +65,44 @@ export function PersonalInfoFields({
]; ];
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}> <Grid container spacing={2}>
<Box <Grid size={{ xs: 12, md: 6 }}>
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%',
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField <TextField
autoFocus
fullWidth
label={t('completion.name')} label={t('completion.name')}
placeholder={t('completion.name')} placeholder={t('completion.name')}
variant="outlined" variant="outlined"
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setFirstName(e.target.value)}
sx={{ flex: '1 1 260px' }}
onBlur={() => handleBlur('firstName')} onBlur={() => handleBlur('firstName')}
error={touched.firstName && !!errors.firstName} error={touched.firstName && !!errors.firstName}
helperText={touched.firstName && errors.firstName} helperText={touched.firstName && errors.firstName}
/> />
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth
label={t('completion.familyName')} label={t('completion.familyName')}
placeholder={t('completion.familyName')} placeholder={t('completion.familyName')}
variant="outlined" variant="outlined"
value={lastName} value={lastName}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setLastName(e.target.value)}
sx={{ flex: '1 1 260px' }}
onBlur={() => handleBlur('lastName')} onBlur={() => handleBlur('lastName')}
error={touched.lastName && !!errors.lastName} error={touched.lastName && !!errors.lastName}
helperText={touched.lastName && errors.lastName} helperText={touched.lastName && errors.lastName}
/> />
</Box> </Grid>
<Box <Grid size={{ xs: 12, md: 6 }}>
sx={{ <FormControl fullWidth error={touched.sex && !!errors.sex}>
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<FormControl sx={{ flex: '1 1 260px' }}>
<InputLabel>{t('completion.gender')}</InputLabel> <InputLabel>{t('completion.gender')}</InputLabel>
<Select <Select
value={sex || ''} value={sex || ''}
label={t('completion.gender')} label={t('completion.gender')}
onChange={handleChangeSex} onChange={handleChangeSex}
onBlur={() => handleBlur('sex')}
> >
{genderOptions.map((g) => ( {genderOptions.map((g) => (
<MenuItem key={g.value} value={g.value}> <MenuItem key={g.value} value={g.value}>
@@ -128,33 +113,19 @@ export function PersonalInfoFields({
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
{touched.sex && errors.sex && (
<FormHelperText>{errors.sex}</FormHelperText>
)}
</FormControl> </FormControl>
</Grid>
<TextField <Grid size={{ xs: 12, md: 6 }}>
label={t('completion.optionalNationalCode')}
placeholder={t('completion.optionalNationalCode')}
value={nationalId}
onChange={(e) => setNationalId(e.target.value)}
variant="outlined"
sx={{ flex: '1 1 260px' }}
onBlur={() => handleBlur('nationalId')}
error={touched.nationalId && !!errors.nationalId}
helperText={touched.nationalId && errors.nationalId}
/>
</Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Autocomplete <Autocomplete
sx={{ flex: '1 1 260px' }} fullWidth
options={countryOptions} options={countryOptions}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
noOptionsText={t('completion.noOption')}
value={currentCountry} value={currentCountry}
onChange={(_, newValue) => setCountry(newValue?.code || '')} onChange={(_, newValue) => setCountry(newValue?.code || '')}
onBlur={() => handleBlur('country')} onBlur={() => handleBlur('country')}
@@ -166,7 +137,6 @@ export function PersonalInfoFields({
style={{ style={{
height: '1.5rem', height: '1.5rem',
width: '1.5rem', width: '1.5rem',
// TODO: Check alignment for better styling definition
marginTop: '-2px', marginTop: '-2px',
marginRight: '4px', marginRight: '4px',
marginLeft: '8px', marginLeft: '8px',
@@ -185,12 +155,25 @@ export function PersonalInfoFields({
)} )}
clearOnEscape clearOnEscape
/> />
</Grid>
<Box sx={{ flex: '1 1 260px' }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label={t('completion.optionalNationalCode')}
placeholder={t('completion.optionalNationalCode')}
value={nationalId}
onChange={(e) => setNationalId(e.target.value)}
variant="outlined"
onBlur={() => handleBlur('nationalId')}
error={touched.nationalId && !!errors.nationalId}
helperText={touched.nationalId && errors.nationalId}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<DateOfBirth value={birthDate} onChange={setBirthDate} /> <DateOfBirth value={birthDate} onChange={setBirthDate} />
</Box> </Grid>
</Box> </Grid>
</Box>
</Box>
); );
} }

View File

@@ -28,9 +28,8 @@ export function SubmitSection({ onSubmit, loading }: SubmitProps) {
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 2, gap: 2,
px: { xs: 2, sm: 0 },
mb: 2, mb: 2,
justifyContent: { xs: 'center', sm: 'flex-start' }, justifyContent: 'flex-start',
}} }}
> >
<Typography <Typography

View File

@@ -49,7 +49,6 @@ export function UserCompletionPage() {
); );
const [countdown, setCountdown] = useState(0); const [countdown, setCountdown] = useState(0);
const [emailVerified, setEmailVerified] = useState(false); const [emailVerified, setEmailVerified] = useState(false);
const [showPasswordValidations, setShowPasswordValidations] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({}); const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [touched, setTouched] = useState<{ [key: string]: boolean }>({}); const [touched, setTouched] = useState<{ [key: string]: boolean }>({});
@@ -73,10 +72,6 @@ export function UserCompletionPage() {
completeUserInformationApi, completeUserInformationApi,
); );
useEffect(() => {
setShowPasswordValidations(password ? !validPassword : false);
}, [password, validPassword]);
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
if (buttonState === 'counting' && countdown > 0) { if (buttonState === 'counting' && countdown > 0) {
@@ -101,6 +96,17 @@ export function UserCompletionPage() {
return; 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 }); const res = await sendCode({ email });
if (res) { if (res) {
@@ -122,14 +128,23 @@ export function UserCompletionPage() {
}; };
const handleVerifyCode = async () => { const handleVerifyCode = async () => {
if (!verificationCode.trim()) { const trimmedCode = verificationCode.trim();
// Manually trigger the validation error and stop // Manually trigger the validation error and stop
if (!trimmedCode) {
setTouched((prev) => ({ ...prev, verificationCode: true })); setTouched((prev) => ({ ...prev, verificationCode: true }));
setErrors((prev) => ({ setErrors((prev) => ({
...prev, ...prev,
verificationCode: t('validation.verificationCodeRequired'), verificationCode: t('validation.verificationCodeRequired'),
})); }));
return; 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 }); const res = await verifyCode({ email, otpCode: verificationCode });
@@ -161,6 +176,7 @@ export function UserCompletionPage() {
verificationCode: showEmail, verificationCode: showEmail,
password: showPasswordSection, password: showPasswordSection,
confirmPassword: showPasswordSection, confirmPassword: showPasswordSection,
sex: true,
}); });
const isValid = validateForm(); const isValid = validateForm();
@@ -207,10 +223,22 @@ export function UserCompletionPage() {
setCodeSent(false); setCodeSent(false);
setEmailVerified(false); setEmailVerified(false);
setVerificationCode(''); 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 = () => { const validateForm = () => {
// TODO: check if need separate validation or same common validation
const newErrors: { [key: string]: string } = {}; const newErrors: { [key: string]: string } = {};
// Rule 1: First Name is required // Rule 1: First Name is required
@@ -235,11 +263,16 @@ export function UserCompletionPage() {
// Rule 6: If verification code sent and email section is active // Rule 6: If verification code sent and email section is active
if (showEmail && codeSent && !emailVerified) { if (showEmail && codeSent && !emailVerified) {
if (!verificationCode.trim()) { const trimmedCode = verificationCode.trim();
if (!trimmedCode) {
// Case 1: The code is required but the field is empty. // Case 1: The code is required but the field is empty.
newErrors.verificationCode = t('validation.verificationCodeRequired'); 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 { } 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'); newErrors.verificationCode = t('validation.mustVerifyCode');
} }
} }
@@ -263,6 +296,10 @@ export function UserCompletionPage() {
} }
} }
if (sex === null) {
newErrors.sex = t('validation.genderRequired');
}
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; // Returns true if form is valid return Object.keys(newErrors).length === 0; // Returns true if form is valid
}; };
@@ -271,6 +308,43 @@ export function UserCompletionPage() {
setTouched((prev) => ({ ...prev, [field]: true })); 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 // re-validate whenever a field the user has touched changes value
useEffect(() => { useEffect(() => {
// Only run validation if at least one field has been touched // Only run validation if at least one field has been touched
@@ -289,6 +363,7 @@ export function UserCompletionPage() {
password, password,
confirmPassword, confirmPassword,
showPasswordSection, showPasswordSection,
sex,
touched, touched,
]); ]);
@@ -300,17 +375,16 @@ export function UserCompletionPage() {
sx={{ sx={{
minHeight: '100vh', minHeight: '100vh',
gap: 3, gap: 3,
// TODO: check if this padding needed for mobile view py: { md: 0, xs: 4 },
py: { sm: 0, xs: 2 },
}} }}
> >
<Logo /> <Logo />
<AuthenticationCard <AuthenticationCard
// TODO: check styles
sx={{ sx={{
maxHeight: { sm: '80vh', xs: 'unset' }, maxHeight: { sm: '80vh', xs: 'unset' },
flex: { sm: 'unset', xs: 1 }, flex: { sm: 'unset', xs: 1 },
overflowY: 'auto', overflowY: 'auto',
scrollbarGutter: 'stable both-edges',
}} }}
maxWidth="730px" maxWidth="730px"
> >
@@ -361,7 +435,6 @@ export function UserCompletionPage() {
hasUpperAndLower={hasUpperAndLower} hasUpperAndLower={hasUpperAndLower}
hasSpecialChar={hasSpecialChar} hasSpecialChar={hasSpecialChar}
validPassword={validPassword} validPassword={validPassword}
showValidations={showPasswordValidations}
errors={errors} errors={errors}
touched={touched} touched={touched}
handleBlur={handleBlur} handleBlur={handleBlur}

View File

@@ -49,7 +49,6 @@ export interface PasswordSectionProps extends ValidationProps {
hasUpperAndLower: boolean; hasUpperAndLower: boolean;
hasSpecialChar: boolean; hasSpecialChar: boolean;
validPassword: boolean; validPassword: boolean;
showValidations: boolean;
} }
export interface ValidationItemProps { export interface ValidationItemProps {