fix: validation states, responsive ui, ui states
This commit is contained in:
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "کد تایید باید ۴ رقم باشد."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user