Merge branch 'develop' into fix/completion-page
This commit is contained in:
470
src/features/authentication/routes/UserCompletionPage.tsx
Normal file
470
src/features/authentication/routes/UserCompletionPage.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Logo from '@/components/Logo';
|
||||
import { PersonalInfoFields } from '../components/UserCompletionForm/PersonalInfoFields';
|
||||
import { PasswordSection } from '../components/UserCompletionForm/PasswordSection';
|
||||
import { EmailSection } from '../components/UserCompletionForm/EmailSection';
|
||||
import { SubmitSection } from '../components/UserCompletionForm/SubmitSection';
|
||||
import { useToast } from '@rkheftan/harmony-ui';
|
||||
import { Gender } from '../types/settingForm';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import {
|
||||
sendEmailOtpApi,
|
||||
confirmEmailOtpApi,
|
||||
completeUserInformationApi,
|
||||
} from '../api/userCompletion';
|
||||
import { containsSymbol } from '@/utils/regexes/containsSymbol';
|
||||
import { least8Chars } from '@/utils/regexes/least8Chars';
|
||||
import { containsNumber } from '@/utils/regexes/containsNumber';
|
||||
import { hasUpperAndLowerLetter } from '@/utils/regexes/hasUpperAndLowerLetter';
|
||||
import { isEmail } from '@/utils/regexes/isEmail';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { FlexBox } from '@/components/common/FlexBox';
|
||||
import { AuthenticationCard } from '../components/AuthenticationCard';
|
||||
import { nationalIdRegex } from '@/utils/regexes/nationalId';
|
||||
|
||||
export function UserCompletionPage() {
|
||||
const [params] = useSearchParams();
|
||||
const { t } = useTranslation('completionForm');
|
||||
const showToast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [nationalId, setNationalId] = useState('');
|
||||
const [birthDate, setBirthDate] = useState<Date | null>(null);
|
||||
const [sex, setSex] = useState<Gender | null>(null);
|
||||
const [country, setCountry] = useState('');
|
||||
const [showPasswordSection, setShowPasswordSection] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showEmail, setShowEmail] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [buttonState, setButtonState] = useState<'default' | 'counting'>(
|
||||
'default',
|
||||
);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [emailVerified, setEmailVerified] = useState(false);
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
const [touched, setTouched] = useState<{ [key: string]: boolean }>({});
|
||||
|
||||
const hasNumber = containsNumber(password);
|
||||
const hasMinLength = least8Chars(password);
|
||||
const hasUpperAndLower = hasUpperAndLowerLetter(password);
|
||||
const hasSpecialChar = containsSymbol(password);
|
||||
const validPassword =
|
||||
hasNumber && hasMinLength && hasUpperAndLower && hasSpecialChar;
|
||||
|
||||
const matchPassword = password === confirmPassword;
|
||||
const correctEmail = isEmail(email);
|
||||
|
||||
const { execute: sendCode, loading: isSendingCode } = useApi(sendEmailOtpApi);
|
||||
|
||||
const { execute: verifyCode, loading: isVerifyingCode } =
|
||||
useApi(confirmEmailOtpApi);
|
||||
|
||||
const { execute: submitForm, loading: isSubmitting } = useApi(
|
||||
completeUserInformationApi,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (buttonState === 'counting' && countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
setButtonState('default');
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [buttonState, countdown]);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!isEmail(email)) {
|
||||
setTouched((prev) => ({ ...prev, email: true }));
|
||||
setErrors((prev) => ({ ...prev, email: t('validation.emailInvalid') }));
|
||||
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) {
|
||||
if (res.success) {
|
||||
showToast({
|
||||
message: res.message || t('completion.successfulCodeSent'),
|
||||
severity: 'success',
|
||||
});
|
||||
setCodeSent(true);
|
||||
setButtonState('counting');
|
||||
setCountdown(120);
|
||||
} else {
|
||||
showToast({
|
||||
message: res.message || t('completion.problem'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
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 });
|
||||
|
||||
if (res) {
|
||||
if (res.success) {
|
||||
setEmailVerified(true);
|
||||
showToast({
|
||||
message: res.message || t('completion.codeVerified'),
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: res.message || t('completion.invalidCode'),
|
||||
severity: 'error',
|
||||
});
|
||||
setEmailVerified(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setTouched({
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
country: true,
|
||||
email: showEmail,
|
||||
nationalId: true,
|
||||
verificationCode: showEmail,
|
||||
password: showPasswordSection,
|
||||
confirmPassword: showPasswordSection,
|
||||
sex: true,
|
||||
});
|
||||
|
||||
const isValid = validateForm();
|
||||
if (!isValid) {
|
||||
return; // Stop the submission
|
||||
}
|
||||
|
||||
const res = await submitForm({
|
||||
firstName,
|
||||
lastName,
|
||||
gender: sex || 0,
|
||||
nationalId,
|
||||
savePassword: showPasswordSection,
|
||||
password: showPasswordSection ? password : undefined,
|
||||
saveEmail: showEmail,
|
||||
email: showEmail ? email : undefined,
|
||||
birthDate,
|
||||
country,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
if (res.success) {
|
||||
showToast({
|
||||
message: res.message || t('completion.submitSuccess'),
|
||||
severity: 'success',
|
||||
});
|
||||
|
||||
const returnUrl = params.get('returnUrl');
|
||||
|
||||
navigate(
|
||||
returnUrl ? `/account-created?returnUrl=${returnUrl}` : '/setting',
|
||||
);
|
||||
} else {
|
||||
showToast({
|
||||
message: res.message || t('completion.submitError'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEmail = () => {
|
||||
setButtonState('default');
|
||||
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 = () => {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
// Rule 1: First Name is required
|
||||
if (!firstName.trim())
|
||||
newErrors.firstName = t('validation.firstNameRequired');
|
||||
|
||||
// Rule 2: Last Name is required
|
||||
if (!lastName.trim()) newErrors.lastName = t('validation.lastNameRequired');
|
||||
|
||||
// Rule 3: Country is required
|
||||
if (!country) newErrors.country = t('validation.countryRequired');
|
||||
|
||||
// Rule 4: Email is required and must be valid IF the section is shown
|
||||
if (showEmail && !isEmail(email)) {
|
||||
newErrors.email = t('validation.emailInvalid');
|
||||
}
|
||||
|
||||
// Rule 5: National ID must be 10 digits IF it's not empty
|
||||
if (nationalId && !nationalIdRegex.test(nationalId)) {
|
||||
newErrors.nationalId = t('validation.nationalIdInvalid');
|
||||
}
|
||||
|
||||
// Rule 6: If verification code sent and email section is active
|
||||
if (showEmail && codeSent && !emailVerified) {
|
||||
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 3: The user has typed a code but hasn't clicked "Verify" yet.
|
||||
newErrors.verificationCode = t('validation.mustVerifyCode');
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 7: Password validation
|
||||
if (showPasswordSection) {
|
||||
// Rule 1: Check if the main password is valid
|
||||
if (!password.trim()) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (!validPassword) {
|
||||
// 'validPassword' is the boolean you already calculate
|
||||
newErrors.password = t('validation.passwordInvalid');
|
||||
}
|
||||
|
||||
// Rule 2: Check if the confirmation password matches
|
||||
if (!confirmPassword.trim()) {
|
||||
newErrors.confirmPassword = t('validation.confirmPasswordRequired');
|
||||
} else if (!matchPassword) {
|
||||
// 'matchPassword' is the boolean you already calculate
|
||||
newErrors.confirmPassword = t('validation.passwordsDoNotMatch');
|
||||
}
|
||||
}
|
||||
|
||||
if (sex === null) {
|
||||
newErrors.sex = t('validation.genderRequired');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0; // Returns true if form is valid
|
||||
};
|
||||
|
||||
const handleBlur = (field: string) => {
|
||||
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
|
||||
if (Object.keys(touched).length > 0) {
|
||||
validateForm();
|
||||
}
|
||||
}, [
|
||||
firstName,
|
||||
lastName,
|
||||
country,
|
||||
email,
|
||||
nationalId,
|
||||
verificationCode,
|
||||
codeSent,
|
||||
emailVerified,
|
||||
password,
|
||||
confirmPassword,
|
||||
showPasswordSection,
|
||||
sex,
|
||||
touched,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FlexBox
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
gap: 3,
|
||||
py: { md: 0, xs: 4 },
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<AuthenticationCard
|
||||
sx={{
|
||||
maxHeight: { sm: '80vh', xs: 'unset' },
|
||||
flex: { sm: 'unset', xs: 1 },
|
||||
overflowY: 'auto',
|
||||
scrollbarGutter: 'stable both-edges',
|
||||
}}
|
||||
maxWidth="730px"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="h5" color="text.primary">
|
||||
{t('completion.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('completion.description')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<PersonalInfoFields
|
||||
firstName={firstName}
|
||||
setFirstName={setFirstName}
|
||||
lastName={lastName}
|
||||
setLastName={setLastName}
|
||||
nationalId={nationalId}
|
||||
setNationalId={setNationalId}
|
||||
birthDate={birthDate}
|
||||
setBirthDate={setBirthDate}
|
||||
sex={sex}
|
||||
setSex={setSex}
|
||||
country={country}
|
||||
setCountry={setCountry}
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
handleBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<PasswordSection
|
||||
showPasswordSection={showPasswordSection}
|
||||
setShowPasswordSection={setShowPasswordSection}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
confirmPassword={confirmPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
matchPassword={matchPassword}
|
||||
hasNumber={hasNumber}
|
||||
hasMinLength={hasMinLength}
|
||||
hasUpperAndLower={hasUpperAndLower}
|
||||
hasSpecialChar={hasSpecialChar}
|
||||
validPassword={validPassword}
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
handleBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<EmailSection
|
||||
showEmail={showEmail}
|
||||
setShowEmail={setShowEmail}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
correctEmail={correctEmail}
|
||||
codeSent={codeSent}
|
||||
verificationCode={verificationCode}
|
||||
setVerificationCode={setVerificationCode}
|
||||
buttonState={buttonState}
|
||||
countdown={countdown}
|
||||
handleSendCode={handleSendCode}
|
||||
handleVerifyCode={handleVerifyCode}
|
||||
emailVerified={emailVerified}
|
||||
isVerifyingCode={isVerifyingCode}
|
||||
isSendingCode={isSendingCode}
|
||||
handleEditEmail={handleEditEmail}
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
handleBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<SubmitSection onSubmit={handleSubmit} loading={isSubmitting} />
|
||||
</Box>
|
||||
</AuthenticationCard>
|
||||
</FlexBox>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user