fix: accounts review

This commit is contained in:
Koosha Lahouti
2025-09-22 12:04:54 +03:30
parent a42da2d4c4
commit 21b7fb27ce
16 changed files with 210 additions and 157 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import {
DatePicker,
PickersDay,
@@ -13,7 +13,6 @@ import { getDay } from 'date-fns-jalali';
import { format as formatJalali } from 'date-fns-jalali';
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';
@@ -21,6 +20,7 @@ import { Calendar } from 'iconsax-react';
export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
const { t, i18n } = useTranslation('completionForm');
const isFarsi = i18n.language === 'fa' || i18n.language === 'fa-IR';
const [openView, setOpenView] = useState<'year' | 'month' | 'day'>('year');
const { Adapter, locale, formatString, dayOfWeekFormatter } = useMemo(() => {
if (isFarsi) {
@@ -44,11 +44,7 @@ export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
const dayNumber = isFarsi
? formatJalali(props.day, 'dd')
: format(props.day, 'dd');
return (
<PickersDay {...props}>
{toLocaleDigits(dayNumber, i18n.language)}
</PickersDay>
);
return <PickersDay {...props}>{dayNumber}</PickersDay>;
};
const CustomCalendarIcon = () => (
@@ -58,23 +54,23 @@ export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
return (
<LocalizationProvider dateAdapter={Adapter} adapterLocale={locale}>
<DatePicker
label={toLocaleDigits(t('completion.dateOfBirth'), i18n.language)}
label={t('completion.dateOfBirth')}
value={value}
onChange={onChange}
format={formatString}
dayOfWeekFormatter={dayOfWeekFormatter}
slots={{ day: CustomDay, openPickerIcon: CustomCalendarIcon }}
disableFuture
openTo="year"
views={['year', 'month', 'day']}
view={openView}
onViewChange={(newView) => setOpenView(newView)}
onYearChange={() => setOpenView('month')}
slotProps={{
textField: {
fullWidth: true,
},
textField: { fullWidth: true },
popper: {
sx: {
'& .MuiDateCalendar-root': {
// TODO: fix this to use textfield width instead of defining hardcode
width: '309px',
},
'& .MuiDateCalendar-root': { width: '309px' },
},
},
}}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import {
TextField,
Box,
@@ -13,30 +13,35 @@ import { useTranslation } from 'react-i18next';
import { TickCircle, Edit2 } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type EmailSectionProps } from '../../types/settingForm';
import { toLocaleDigits } from '@/utils/persianDigit';
import { isNumeric } from '@/utils/regexes/isNumeric';
import { sanitizeLocalNumber } from '@/utils/regexes/sanitizeNumber';
export function EmailSection({
showEmail,
setShowEmail,
email,
setEmail,
codeSent,
verificationCode,
setVerificationCode,
buttonState,
handleSendCode,
handleVerifyCode,
emailVerified,
isVerifyingCode,
isSendingCode,
handleEditEmail,
errors,
touched,
handleBlur,
countdown,
}: EmailSectionProps) {
const { t, i18n } = useTranslation('completionForm');
const ADORN_W = 24;
const INPUT_H = 56;
export function EmailSection(props: EmailSectionProps) {
const {
showEmail,
setShowEmail,
email,
setEmail,
codeSent,
verificationCode,
setVerificationCode,
buttonState,
handleSendCode,
handleVerifyCode,
emailVerified,
isVerifyingCode,
isSendingCode,
handleEditEmail,
errors,
touched,
handleBlur,
countdown,
} = props;
const { t } = useTranslation('completionForm');
const handleToggleEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowEmail(e.target.checked);
@@ -45,29 +50,31 @@ export function EmailSection({
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 normalized = sanitizeLocalNumber(e.target.value);
if (isNumeric(normalized) && normalized.length <= 4) {
setVerificationCode(normalized);
}
};
const formatTimerValue = () => {
const m = String(Math.floor(countdown / 60)).padStart(2, '0');
const s = String(countdown % 60).padStart(2, '0');
return toLocaleDigits(`${m}:${s}`, i18n.language);
return `${m}:${s}`;
};
useEffect(() => {
if (buttonState !== 'counting') {
setVerificationCode('');
}
}, [buttonState, setVerificationCode]);
const showCodeSection =
showEmail && codeSent && !emailVerified && buttonState === 'counting';
return (
<>
<FormGroup>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Switch checked={showEmail} onChange={handleToggleEmail} />
<Typography
sx={{ color: showEmail ? 'primary.main' : 'text.primary' }}
@@ -76,14 +83,9 @@ export function EmailSection({
</Typography>
</Box>
</FormGroup>
{showEmail && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box
sx={{
display: 'flex',
@@ -97,40 +99,75 @@ export function EmailSection({
variant="outlined"
fullWidth
value={email}
autoFocus
onChange={(e) => 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' }}
sx={{
flex: '1 1 260px',
'& .MuiOutlinedInput-root': { height: INPUT_H },
}}
slotProps={{
input: {
startAdornment:
!isVerifyingCode && emailVerified ? (
<InputAdornment position="start">
<Icon
Component={TickCircle}
size="medium"
variant="Bold"
color="success.main"
/>
</InputAdornment>
) : null,
endAdornment: codeSent ? (
<InputAdornment position="end">
<IconButton onClick={handleEditEmail}>
<Icon
Component={Edit2}
color="primary.main"
size="medium"
/>
</IconButton>
startAdornment: (
<InputAdornment position="start" sx={{ width: ADORN_W }}>
<Box
sx={{
width: ADORN_W,
display: 'flex',
justifyContent: 'center',
}}
>
<Box
sx={{
alignItems: 'center',
visibility:
!isVerifyingCode && emailVerified
? 'visible'
: 'hidden',
}}
>
<Icon
Component={TickCircle}
size="medium"
variant="Bold"
color="success.main"
/>
</Box>
</Box>
</InputAdornment>
) : null,
),
endAdornment: (
<InputAdornment position="end" sx={{ width: ADORN_W }}>
<Box
sx={{
width: ADORN_W,
display: 'flex',
justifyContent: 'center',
}}
>
<Box
sx={{ visibility: codeSent ? 'visible' : 'hidden' }}
>
<IconButton
onClick={handleEditEmail}
disabled={!codeSent}
>
<Icon
Component={Edit2}
color="primary.main"
size="medium"
/>
</IconButton>
</Box>
</Box>
</InputAdornment>
),
},
}}
/>
{!isVerifyingCode &&
!emailVerified &&
(buttonState === 'counting' ? (
@@ -140,6 +177,8 @@ export function EmailSection({
width: { xs: '100%', sm: '156px' },
alignSelf: 'center',
textAlign: 'center',
lineHeight: `${INPUT_H}px`,
height: INPUT_H,
}}
color="primary"
variant="body1"
@@ -154,41 +193,53 @@ export function EmailSection({
sx={{
width: { xs: '100%', sm: '156px' },
alignSelf: 'center',
height: INPUT_H,
}}
>
{t('completion.vericationCodeButton')}
</Button>
))}
</Box>
{!emailVerified && codeSent && (
{showCodeSection && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
alignItems: 'stretch',
}}
>
<TextField
label={t('completion.verificationCode')}
autoFocus
variant="outlined"
type="text"
value={verificationCode}
onChange={handleVerificationCodeChange}
sx={{ flex: '1 1 260px' }}
sx={{
flex: '1 1 260px',
'& .MuiOutlinedInput-root': { height: INPUT_H },
}}
disabled={isVerifyingCode}
onBlur={() => handleBlur('verificationCode')}
error={touched.verificationCode && !!errors.verificationCode}
helperText={touched.verificationCode && errors.verificationCode}
inputProps={{
inputMode: 'numeric',
pattern: '[0-9]*',
maxLength: 4,
}}
/>
<Button
variant="outlined"
onClick={handleVerifyCode}
loading={isVerifyingCode}
sx={{
width: { xs: '100%', sm: '156px' },
alignSelf: 'center',
alignSelf: 'stretch',
height: INPUT_H,
}}
>
{t('completion.checkCodeButton')}

View File

@@ -18,6 +18,7 @@ import { Icon } from '@rkheftan/harmony-ui';
import { type PersonalInfoFieldsProps } from '../../types/settingForm';
import { Gender } from '../../types/settingForm';
import ReactCountryFlag from 'react-country-flag';
import { sanitizeLocalNumber } from '@/utils/regexes/sanitizeNumber';
export function PersonalInfoFields({
firstName,
@@ -129,6 +130,7 @@ export function PersonalInfoFields({
value={currentCountry}
onChange={(_, newValue) => setCountry(newValue?.code || '')}
onBlur={() => handleBlur('country')}
autoFocus
renderOption={(props, option) => (
<Box component="li" {...props} key={option.code}>
<ReactCountryFlag
@@ -163,7 +165,13 @@ export function PersonalInfoFields({
label={t('completion.optionalNationalCode')}
placeholder={t('completion.optionalNationalCode')}
value={nationalId}
onChange={(e) => setNationalId(e.target.value)}
onChange={(e) => {
const normalized = sanitizeLocalNumber(e.target.value);
if (normalized.length <= 10) {
setNationalId(normalized);
}
}}
variant="outlined"
onBlur={() => handleBlur('nationalId')}
error={touched.nationalId && !!errors.nationalId}

View File

@@ -7,9 +7,12 @@ import {
Dialog,
DialogTitle,
DialogContent,
IconButton,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { type SubmitProps } from '../../types/settingForm';
import { CloseCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
export function SubmitSection({ loading }: SubmitProps) {
const { t, i18n } = useTranslation('completionForm');
@@ -21,6 +24,7 @@ export function SubmitSection({ loading }: SubmitProps) {
};
const agreementText = t('completion.agreement');
return (
<>
<Box
@@ -72,7 +76,16 @@ export function SubmitSection({ loading }: SubmitProps) {
maxWidth="md"
dir={i18n.language.startsWith('fa') ? 'rtl' : 'ltr'}
>
<DialogTitle>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<IconButton onClick={() => setOpenDialog(false)}>
<Icon Component={CloseCircle} size="medium" color="primary.main" />
</IconButton>
{t('completion.rules') || t('completion.rules')}
</DialogTitle>

View File

@@ -64,10 +64,8 @@ export function UserCompletionPage() {
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,
);
@@ -89,13 +87,30 @@ export function UserCompletionPage() {
return () => clearInterval(timer);
}, [buttonState, countdown]);
useEffect(() => {
if (buttonState === 'default') {
setCodeSent(false);
setVerificationCode('');
// setEmailVerified(false);
setTouched((prev) => {
const n = { ...prev };
delete n.verificationCode;
return n;
});
setErrors((prev) => {
const n = { ...prev };
delete n.verificationCode;
return n;
});
}
}, [buttonState]);
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;
@@ -106,13 +121,11 @@ export function UserCompletionPage() {
delete newErrors.verificationCode;
return newErrors;
});
const res = await sendCode({ email });
if (res) {
if (res.success) {
showToast({
message: res.message || t('completion.successfulCodeSent'),
message: t('completion.successfulCodeSent'),
severity: 'success',
});
setCodeSent(true);
@@ -129,8 +142,6 @@ export function UserCompletionPage() {
const handleVerifyCode = async () => {
const trimmedCode = verificationCode.trim();
// Manually trigger the validation error and stop
if (!trimmedCode) {
setTouched((prev) => ({ ...prev, verificationCode: true }));
setErrors((prev) => ({
@@ -146,9 +157,7 @@ export function UserCompletionPage() {
}));
return;
}
const res = await verifyCode({ email, otpCode: verificationCode });
if (res) {
if (res.success) {
setEmailVerified(true);
@@ -178,12 +187,10 @@ export function UserCompletionPage() {
confirmPassword: showPasswordSection,
sex: true,
});
const isValid = validateForm();
if (!isValid) {
return; // Stop the submission
return;
}
const res = await submitForm({
firstName,
lastName,
@@ -196,16 +203,13 @@ export function UserCompletionPage() {
birthDate,
countryCode: 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',
);
@@ -223,7 +227,6 @@ export function UserCompletionPage() {
setCodeSent(false);
setEmailVerified(false);
setVerificationCode('');
// We clear both touched and errors
setTouched((prev) => {
const newTouched = { ...prev };
delete newTouched.email;
@@ -240,77 +243,49 @@ export function UserCompletionPage() {
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
return Object.keys(newErrors).length === 0;
};
const handleBlur = (field: string) => {
setTouched((prev) => ({ ...prev, [field]: true }));
};
const handleBlur = (_field: string) => {};
useEffect(() => {
if (!showPasswordSection) {
// We clear both touched and errors to prevent lingering validation messages
setTouched((prev) => {
const newTouched = { ...prev };
delete newTouched.password;
@@ -326,10 +301,8 @@ export function UserCompletionPage() {
}
}, [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;
@@ -345,9 +318,7 @@ export function UserCompletionPage() {
}
}, [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();
}

View File

@@ -13,7 +13,7 @@ import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../../hooks/useProfile';
export function PersonalInformation() {
const imageBaseUrl = import.meta.env.IMAGE_BASE_URL;
const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_URL;
const { t } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
@@ -50,9 +50,10 @@ export function PersonalInformation() {
setOriginalData(fetchedData);
setUploadedImageUrl(
profileData.profileImageUrl
? `${imageBaseUrl}${profileData.profileImageUrl}`
? `${imageBaseUrl}/${profileData.profileImageUrl}`
: null,
);
console.log(uploadedImageUrl);
setUploadedImageFile(null);
} else {
showToast({

View File

@@ -103,7 +103,7 @@ export function SocialMedia() {
if (sendCodeData.success) {
toast({
message: sendCodeData.message || t('settingForm.verificationCodeSent'),
message: t('settingForm.verificationCodeSent'),
severity: 'success',
});
setDialogStep('enterCode');

View File

@@ -126,7 +126,7 @@ export default function SocialMediaDialog({
placeholder="abc@email.com"
autoComplete="email"
inputMode="email"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 }, mt: 1 }}
autoFocus={dialogStep === 'enterEmail'}
disabled={isLoading || dialogStep === 'enterCode'}
error={touched && !!emailError}
@@ -142,7 +142,7 @@ export default function SocialMediaDialog({
label={t('settingForm.verificationCode')}
autoComplete="one-time-code"
inputMode="numeric"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
autoFocus
disabled={isLoading}
/>
@@ -152,7 +152,7 @@ export default function SocialMediaDialog({
<Box sx={{ px: 3, pb: 2 }}>
<Button
fullWidth
sx={{ height: 48, textTransform: 'none', borderRadius: 2 }}
sx={{ height: 48, textTransform: 'none', borderRadius: 1 }}
variant="contained"
type="submit"
disabled={isLoading || (dialogStep === 'enterEmail' && !emailInput)}

View File

@@ -7,7 +7,7 @@ import {
ListItemIcon,
ListItemText,
} from '@mui/material';
import { Message, Google, Apple, ArrowDown3 } from 'iconsax-react';
import { Message, Google, ArrowDown3 } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaMenuProps } from '@/features/profile/types/settingsType';
@@ -61,9 +61,9 @@ export default function SocialMediaMenu({
<Box component="span">{t('settingForm.addEmailOrSocialButton')}</Box>
<Icon
Component={ArrowDown3}
size="medium"
size="small"
color="primary.main"
variant={open ? 'Bold' : 'Outline'}
variant="Outline"
/>
</Button>
</Box>
@@ -98,12 +98,6 @@ export default function SocialMediaMenu({
</ListItemIcon>
<ListItemText>{t('settingForm.google')}</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<Icon Component={Apple} size="medium" color="primary.main" />
</ListItemIcon>
<ListItemText>{t('settingForm.apple')}</ListItemText>
</MenuItem>
</Menu>
</Box>
);

View File

@@ -0,0 +1,9 @@
const PERSIAN = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC = '٠١٢٣٤٥٦٧٨٩';
export const normalizeDigits = (str: string) =>
str.replace(/[\u06F0-\u06F9\u0660-\u0669]/g, (d) => {
const iP = PERSIAN.indexOf(d);
if (iP !== -1) return String(iP);
const iA = ARABIC.indexOf(d);
return iA !== -1 ? String(iA) : d;
});

View File

@@ -0,0 +1,4 @@
import { normalizeDigits } from './normalizeDigits';
export const sanitizeLocalNumber = (v: string) =>
normalizeDigits(v).replace(/\D+/g, '');