chore: change styles and add Api for user profile
This commit is contained in:
1786
package-lock.json
generated
1786
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.1",
|
|
||||||
"@mui/material": "^7.3.1",
|
"@mui/material": "^7.3.1",
|
||||||
"@mui/stylis-plugin-rtl": "^7.2.0",
|
"@mui/stylis-plugin-rtl": "^7.2.0",
|
||||||
"@mui/x-data-grid": "^8.10.0",
|
"@mui/x-data-grid": "^8.10.0",
|
||||||
"@mui/x-virtualizer": "^0.1.1",
|
"@mui/x-virtualizer": "^0.1.1",
|
||||||
"@rkheftan/harmony-ui": "^0.1.4",
|
"@rkheftan/harmony-ui": "^0.1.6",
|
||||||
|
"axios": "^1.11.0",
|
||||||
"i18next": "^25.3.0",
|
"i18next": "^25.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@types/node": "^24.0.10",
|
"@types/node": "^24.0.10",
|
||||||
"@types/react": "^19.1.9",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/stylis": "^4.2.7",
|
"@types/stylis": "^4.2.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.35.1",
|
"@typescript-eslint/eslint-plugin": "^8.35.1",
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"active": {
|
|
||||||
"activeDevices": "Active devices",
|
|
||||||
"activeDevicesCaption": "Watch and manage all your active devices",
|
|
||||||
"deletDevicesButton": "Remove rest of the devices",
|
|
||||||
"deleteDevice": "Remove device",
|
|
||||||
"currentDevice": "Current device"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"settingForm": {
|
|
||||||
"titlePersonalInfo": "My Personal Information",
|
|
||||||
"descriptionPersonalInfo": "This information is only for your identification and remains with Harmony.",
|
|
||||||
"titlePhoneNumber": "My phone number",
|
|
||||||
"descriptionPhoneNumber": "This information is only for your identification and remains with Harmony.",
|
|
||||||
"titleSocial": "My email and social medias",
|
|
||||||
"descriptionSocial": "This information is only for your identification and remains with Harmony.",
|
|
||||||
"rejectButton": "Cancel",
|
|
||||||
"saveButton": "Save",
|
|
||||||
"editButton": "Edit",
|
|
||||||
"editPhoneNumber": "Change phone number",
|
|
||||||
"addEmailOrSocialButton": "Add email / social",
|
|
||||||
"addEmailButton": "Add email",
|
|
||||||
"name": "Name",
|
|
||||||
"familyName": "Family Name",
|
|
||||||
"country": "Country",
|
|
||||||
"gender": "Gender",
|
|
||||||
"nationalCode": "National code",
|
|
||||||
"man": "Male",
|
|
||||||
"woman": "Female",
|
|
||||||
"genderPlaceholder": "Male",
|
|
||||||
"newPhoneNumber": "New phone number",
|
|
||||||
"verificationCodeButton": "Send verification code",
|
|
||||||
"verificationCode": "Verification code",
|
|
||||||
"checkCode": "Check code",
|
|
||||||
"successButton": "Confirmed",
|
|
||||||
"email": "Email",
|
|
||||||
"apple": "Apple",
|
|
||||||
"google": "Google",
|
|
||||||
"newEmail": "New email",
|
|
||||||
"dialogHeader": "By activating your email, you can use this email to log in the next time you log in.",
|
|
||||||
"or": "Or",
|
|
||||||
"emailError": "Please enter a valid email.",
|
|
||||||
"profilePicture": "User account image",
|
|
||||||
"allowedFormat": "Allowed formats: PNG, JPEG, GIF (maximum 10 MB)",
|
|
||||||
"uploadPicture": "Upload image",
|
|
||||||
"phoneNumberText": "Your new contact number will replace your previous contact number.",
|
|
||||||
"verb": ".",
|
|
||||||
"notDetermined": "Not determined",
|
|
||||||
"successfulChangePhone": "Phone number changed successfully",
|
|
||||||
"phoneNumberIsInvalid": "Phone number is invalid",
|
|
||||||
"thisFieldIsRequired": "This field is required",
|
|
||||||
"changePicture": "Change picture"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"securityForm": {
|
|
||||||
"password": "Password",
|
|
||||||
"determinePassword": "Log in to your Harmony account more easily by setting a strong password.",
|
|
||||||
"addPassword": "Add password",
|
|
||||||
"notDeterminedPassword": "You have not set a password for this account yet.",
|
|
||||||
"newPassword": "New password",
|
|
||||||
"confirmPassword": "Confirm password",
|
|
||||||
"confirm": "Confirm",
|
|
||||||
"hasNumber": "Contains number",
|
|
||||||
"hasMinLength": "at least 8 character",
|
|
||||||
"hasUpperAndLower": "Contains a lowercase and uppercase letter",
|
|
||||||
"hasSpecialChar": "Contains sign (!@#$%^&*)",
|
|
||||||
"notCompatibility": "Confirm password is not the same as password.",
|
|
||||||
"alertSuccess": "Password has successfully changed",
|
|
||||||
"lastChange": "Last change a few seconds ago",
|
|
||||||
"activePassword": "Password is active",
|
|
||||||
"recentLogins": "Recent logins",
|
|
||||||
"description": "In this section, you can see the recent logins to your Harmony account.",
|
|
||||||
"currentDevice": "Current device"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,82 @@
|
|||||||
{
|
{
|
||||||
|
"settingForm": {
|
||||||
|
"titlePersonalInfo": "My Personal Information",
|
||||||
|
"descriptionPersonalInfo": "This information is only for your identification and remains with Harmony.",
|
||||||
|
"titlePhoneNumber": "My phone number",
|
||||||
|
"descriptionPhoneNumber": "This information is only for your identification and remains with Harmony.",
|
||||||
|
"titleSocial": "My email and social medias",
|
||||||
|
"descriptionSocial": "This information is only for your identification and remains with Harmony.",
|
||||||
|
"rejectButton": "Cancel",
|
||||||
|
"saveButton": "Save",
|
||||||
|
"editButton": "Edit",
|
||||||
|
"editPhoneNumber": "Change phone number",
|
||||||
|
"addEmailOrSocialButton": "Add email / social",
|
||||||
|
"addEmailButton": "Add email",
|
||||||
|
"name": "Name",
|
||||||
|
"familyName": "Family Name",
|
||||||
|
"country": "Country",
|
||||||
|
"gender": "Gender",
|
||||||
|
"nationalCode": "National code",
|
||||||
|
"man": "Male",
|
||||||
|
"woman": "Female",
|
||||||
|
"genderPlaceholder": "Male",
|
||||||
|
"newPhoneNumber": "New phone number",
|
||||||
|
"verificationCodeButton": "Send verification code",
|
||||||
|
"verificationCode": "Verification code",
|
||||||
|
"checkCode": "Check code",
|
||||||
|
"successButton": "Confirmed",
|
||||||
|
"email": "Email",
|
||||||
|
"apple": "Apple",
|
||||||
|
"google": "Google",
|
||||||
|
"newEmail": "New email",
|
||||||
|
"dialogHeader": "By activating your email, you can use this email to log in the next time you log in.",
|
||||||
|
"or": "Or",
|
||||||
|
"emailError": "Please enter a valid email.",
|
||||||
|
"profilePicture": "User account image",
|
||||||
|
"allowedFormat": "Allowed formats: PNG, JPEG, GIF (maximum 10 MB)",
|
||||||
|
"uploadPicture": "Upload image",
|
||||||
|
"phoneNumberText": "Your new contact number will replace your previous contact number.",
|
||||||
|
"verb": ".",
|
||||||
|
"notDetermined": "Not determined",
|
||||||
|
"successfulChangePhone": "Phone number changed successfully",
|
||||||
|
"phoneNumberIsInvalid": "Phone number is invalid",
|
||||||
|
"thisFieldIsRequired": "This field is required",
|
||||||
|
"changePicture": "Change picture",
|
||||||
|
"confirmAndSave": "Confirm",
|
||||||
|
"fileSizeError": "Your file exceed the limit",
|
||||||
|
"removePicture": "Remove picture",
|
||||||
|
"failRetrieve": "Failed to retrieve profile data.",
|
||||||
|
"errorFetch": "An error occurred while fetching your profile.",
|
||||||
|
"notLoggedIn": "You are not logged in. Please log in to view your profile.",
|
||||||
|
"unknownError": "An unknown error occurred while saving.",
|
||||||
|
"checkConnection": "Failed to save profile. Please check your connection and try again.",
|
||||||
|
"failFetchPhoneNumber": "Failed to fetch phone number data.",
|
||||||
|
"errorFetchPhoneNumber": "An error occurred while fetching your phone number.",
|
||||||
|
"sendCodeFailed": "Send code failed",
|
||||||
|
"verificationCodeRequired": "Verification code required",
|
||||||
|
"verifyCodeFailed": "Verification of code failed",
|
||||||
|
"changePhoneFailed": "Change of phone number failed",
|
||||||
|
"justNow": "Just now",
|
||||||
|
"failFetchEmail": "Failed to fetch email data",
|
||||||
|
"errorFetchEmail": "An error occurred while fetching your linked accounts.",
|
||||||
|
"emailIsInvalid": "Email is invalid",
|
||||||
|
"changeEmailFailed": "Change of email failed",
|
||||||
|
"anErrorOccurred": "An error occurred."
|
||||||
|
},
|
||||||
|
|
||||||
|
"active": {
|
||||||
|
"activeDevices": "Active devices",
|
||||||
|
"activeDevicesCaption": "Watch and manage all your active devices",
|
||||||
|
"deleteDevicesButton": "Remove rest of the devices",
|
||||||
|
"deleteDevice": "Remove device",
|
||||||
|
"currentDevice": "Current device",
|
||||||
|
"minutesAgo": "{{count}} minutes ago",
|
||||||
|
"justNow": "Just now",
|
||||||
|
"notLoggedIn": "You are not logged in",
|
||||||
|
"failFetchActiveSessions": "Failed to fetch active sessions.",
|
||||||
|
"errorFetch": "An error occurred while fetching your active sessions."
|
||||||
|
},
|
||||||
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Base settings",
|
"title": "Base settings",
|
||||||
"description": "Change your base settings",
|
"description": "Change your base settings",
|
||||||
@@ -13,6 +91,36 @@
|
|||||||
"solar": "Solar",
|
"solar": "Solar",
|
||||||
"lunar": "Lunar",
|
"lunar": "Lunar",
|
||||||
"christian": "Christian",
|
"christian": "Christian",
|
||||||
"iran": "Iran"
|
"iran": "Iran",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"notLoggedIn": "You are not logged in",
|
||||||
|
"failedRetrieve": "Failed to retrieve settings.",
|
||||||
|
"errorFetch": "An error occurred while fetching your settings.",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"invalidSelection": "Invalid selection"
|
||||||
|
},
|
||||||
|
|
||||||
|
"securityForm": {
|
||||||
|
"password": "Password",
|
||||||
|
"determinePassword": "Log in to your Harmony account more easily by setting a strong password.",
|
||||||
|
"addPassword": "Add password",
|
||||||
|
"notDeterminedPassword": "You have not set a password for this account yet.",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"hasNumber": "Contains number",
|
||||||
|
"hasMinLength": "at least 8 character",
|
||||||
|
"hasUpperAndLower": "Contains a lowercase and uppercase letter",
|
||||||
|
"hasSpecialChar": "Contains sign (!@#$%^&*)",
|
||||||
|
"notCompatibility": "Confirm password is not the same as password.",
|
||||||
|
"alertSuccess": "Password has successfully changed",
|
||||||
|
"lastChange": "Last change a few seconds ago",
|
||||||
|
"activePassword": "Password is active",
|
||||||
|
"recentLogins": "Recent logins",
|
||||||
|
"description": "In this section, you can see the recent logins to your Harmony account.",
|
||||||
|
"currentDevice": "Current device",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"currentPassword": "Current password",
|
||||||
|
"forgetPassword": "Forgot your password?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"active": {
|
|
||||||
"activeDevices": "نشست های فعال",
|
|
||||||
"activeDevicesCaption": "مشاهده و مدیریت تمام نشست های فعال شما",
|
|
||||||
"deletDevicesButton": "حذف بقیه نشست ها",
|
|
||||||
"deleteDevice": "حذف نشست",
|
|
||||||
"currentDevice": "دستگاه فعلی"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"settingForm": {
|
|
||||||
"titlePersonalInfo": "اطلاعات شخصی من",
|
|
||||||
"descriptionPersonalInfo": "این اطلاعات شما صرفا برای احراز هویت شما است و نزد هارمونی باقی میماند",
|
|
||||||
"titlePhoneNumber": "شماره تماس من",
|
|
||||||
"descriptionPhoneNumber": "این اطلاعات شما صرفا برای احراز هویت شما است و نزد هارمونی باقی میماند",
|
|
||||||
"titleSocial": "ایمیل و شبکه های اجتماعی من",
|
|
||||||
"descriptionSocial": "این اطلاعات شما صرفاً برای احراز هویت شما است و نزد هارمونی باقی میماند",
|
|
||||||
"rejectButton": "لغو",
|
|
||||||
"saveButton": "ذخیره",
|
|
||||||
"editButton": "ویرایش",
|
|
||||||
"editPhoneNumber": "تغییر شماره تماس",
|
|
||||||
"addEmailOrSocialButton": "افزودن ایمیل / سوشال",
|
|
||||||
"addEmailButton": "افزودن ایمیل",
|
|
||||||
"name": "نام",
|
|
||||||
"familyName": "نام خانوادگی",
|
|
||||||
"country": "کشور",
|
|
||||||
"gender": "جنسیت",
|
|
||||||
"nationalCode": "کد ملی",
|
|
||||||
"man": "مرد",
|
|
||||||
"woman": "زن",
|
|
||||||
"genderPlaceholder": "مرد",
|
|
||||||
"newPhoneNumber": "شماره تماس جدید",
|
|
||||||
"verificationCodeButton": "ارسال کد تایید",
|
|
||||||
"verificationCode": "کد تایید",
|
|
||||||
"checkCode": "بررسی کد",
|
|
||||||
"successButton": "تایید شد",
|
|
||||||
"email": "ایمیل",
|
|
||||||
"apple": "اپل",
|
|
||||||
"google": "گوگل",
|
|
||||||
"newEmail": "ایمیل جدید",
|
|
||||||
"dialogHeader": "با فعالسازی ایمیل میتوانید در دفعات بعدی ورود برای ورود از این ایمیل استفاده کنید",
|
|
||||||
"or": "یا",
|
|
||||||
"emailError": "لطفا یک ایمیل معتبر وارد کنید",
|
|
||||||
"profilePicture": "تصویر حساب کاربری",
|
|
||||||
"allowedFormat": "فرمتهای مجاز: PNG، JPEG، GIF (حداکثر ۱۰ مگابایت)",
|
|
||||||
"uploadPicture": "بارگذاری تصویر",
|
|
||||||
"phoneNumberText": "شماره تماس جدید شما جایگزین شماره تماس قبلی",
|
|
||||||
"verb": "خواهد شد",
|
|
||||||
"notDetermined": "تعیین نشده",
|
|
||||||
"successfulChangePhone": "شماره تماس با موفقیت تغییر کرد",
|
|
||||||
"phoneNumberIsInvalid": "شماره وارد شده نامعتبر میباشد",
|
|
||||||
"thisFieldIsRequired": "این فیلد الزامی است",
|
|
||||||
"changePicture": "تغییر تصویر"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"securityForm": {
|
|
||||||
"password": "رمز عبور",
|
|
||||||
"determinePassword": "با تعیین یک رمز عبور قوی راحت تر به اکانت هارمونی خود وارد شوید",
|
|
||||||
"addPassword": "افزودن رمز عبور",
|
|
||||||
"notDeterminedPassword": "هنوز رمز عبوری برای این حساب کاربری تعیین نکرده اید",
|
|
||||||
"newPassword": "رمز عبور جدید",
|
|
||||||
"confirmPassword": "تکرار رمز عبور",
|
|
||||||
"confirm": "تایید",
|
|
||||||
"hasNumber": "شامل عدد",
|
|
||||||
"hasMinLength": "حداقل 8 کاراکتر",
|
|
||||||
"hasUpperAndLower": "شامل یک حرف کوچک و بزرگ",
|
|
||||||
"hasSpecialChar": "شامل علامت (!@#$%^&*)",
|
|
||||||
"notCompatibility": "تکرار رمز عبور با رمز عبور یکسان نمی باشد",
|
|
||||||
"alertSuccess": "رمز عبور با موفقیت تعویض شد",
|
|
||||||
"lastChange": "آخرین تغییر چند ثانیه پیش",
|
|
||||||
"activePassword": "رمز عبور فعال است",
|
|
||||||
"recentLogins": "ورود های اخیر",
|
|
||||||
"description": "در این بخش از ورود های اخیر به اکانت هارمونی خود را مشاهده می کنید",
|
|
||||||
"currentDevice": "دستگاه فعلی",
|
|
||||||
"changePassword": "تغییر رمز عبور",
|
|
||||||
"currentPassword": "رمز عبور فعلی",
|
|
||||||
"forgetPassword": "رمز عبور را فراموش کرده اید؟"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,82 @@
|
|||||||
{
|
{
|
||||||
|
"settingForm": {
|
||||||
|
"titlePersonalInfo": "اطلاعات شخصی من",
|
||||||
|
"descriptionPersonalInfo": "این اطلاعات شما صرفا برای احراز هویت شما است و نزد هارمونی باقی میماند",
|
||||||
|
"titlePhoneNumber": "شماره تماس من",
|
||||||
|
"descriptionPhoneNumber": "این اطلاعات شما صرفا برای احراز هویت شما است و نزد هارمونی باقی میماند",
|
||||||
|
"titleSocial": "ایمیل و شبکه های اجتماعی من",
|
||||||
|
"descriptionSocial": "این اطلاعات شما صرفاً برای احراز هویت شما است و نزد هارمونی باقی میماند",
|
||||||
|
"rejectButton": "لغو",
|
||||||
|
"saveButton": "ذخیره",
|
||||||
|
"editButton": "ویرایش",
|
||||||
|
"editPhoneNumber": "تغییر شماره تماس",
|
||||||
|
"addEmailOrSocialButton": "افزودن ایمیل / سوشال",
|
||||||
|
"addEmailButton": "افزودن ایمیل",
|
||||||
|
"name": "نام",
|
||||||
|
"familyName": "نام خانوادگی",
|
||||||
|
"country": "کشور",
|
||||||
|
"gender": "جنسیت",
|
||||||
|
"nationalCode": "کد ملی",
|
||||||
|
"man": "مرد",
|
||||||
|
"woman": "زن",
|
||||||
|
"genderPlaceholder": "مرد",
|
||||||
|
"newPhoneNumber": "شماره تماس جدید",
|
||||||
|
"verificationCodeButton": "ارسال کد تایید",
|
||||||
|
"verificationCode": "کد تایید",
|
||||||
|
"checkCode": "بررسی کد",
|
||||||
|
"successButton": "تایید شد",
|
||||||
|
"email": "ایمیل",
|
||||||
|
"apple": "اپل",
|
||||||
|
"google": "گوگل",
|
||||||
|
"newEmail": "ایمیل جدید",
|
||||||
|
"dialogHeader": "با فعالسازی ایمیل میتوانید در دفعات بعدی ورود برای ورود از این ایمیل استفاده کنید",
|
||||||
|
"or": "یا",
|
||||||
|
"emailError": "لطفا یک ایمیل معتبر وارد کنید",
|
||||||
|
"profilePicture": "تصویر حساب کاربری",
|
||||||
|
"allowedFormat": "فرمتهای مجاز: PNG، JPEG، GIF (حداکثر ۱۰ مگابایت)",
|
||||||
|
"uploadPicture": "بارگذاری تصویر",
|
||||||
|
"phoneNumberText": "شماره تماس جدید شما جایگزین شماره تماس قبلی",
|
||||||
|
"verb": "خواهد شد",
|
||||||
|
"notDetermined": "تعیین نشده",
|
||||||
|
"successfulChangePhone": "شماره تماس با موفقیت تغییر کرد",
|
||||||
|
"phoneNumberIsInvalid": "شماره وارد شده نامعتبر میباشد",
|
||||||
|
"thisFieldIsRequired": "این فیلد الزامی است",
|
||||||
|
"changePicture": "تغییر تصویر",
|
||||||
|
"confirmAndSave": "تایید",
|
||||||
|
"fileSizeError": "حجم فایل شما از حد مجاز بیشتر شده است",
|
||||||
|
"removePicture": "حذف عکس",
|
||||||
|
"failRetrieve": "بازیابی اطلاعات پروفایل ناموفق بود.",
|
||||||
|
"errorFetch": "هنگام دریافت پروفایل شما خطایی رخ داد.",
|
||||||
|
"notLoggedIn": "شما وارد سیستم نشدهاید. لطفا برای مشاهده پروفایل خود وارد شوید.",
|
||||||
|
"unknownError": "هنگام ذخیره خطای ناشناختهای رخ داد.",
|
||||||
|
"checkConnection": "ذخیره پروفایل ناموفق بود. لطفاً اتصال خود را بررسی کرده و دوباره امتحان کنید.",
|
||||||
|
"failFetchPhoneNumber": "دریافت اطلاعات شماره تلفن ناموفق بود.",
|
||||||
|
"errorFetchPhoneNumber": "هنگام دریافت شماره تلفن شما خطایی روی داد.",
|
||||||
|
"sendCodeFailed": "خطا در ارسال کد",
|
||||||
|
"verificationCodeRequired": "کد تأیید مورد نیاز است",
|
||||||
|
"verifyCodeFailed": "تأیید کد ناموفق بود",
|
||||||
|
"changePhoneFailed": "تغییر شماره تلفن ناموفق بود",
|
||||||
|
"justNow": "همین الان",
|
||||||
|
"failFetchEmail": "دریافت دادههای ایمیل ناموفق بود",
|
||||||
|
"errorFetchEmail": "هنگام دریافت حسابهای پیوند داده شده شما خطایی روی داد.",
|
||||||
|
"emailIsInvalid": "ایمیل نامعتبر است",
|
||||||
|
"changeEmailFailed": "تغییر ایمیل با خطا مواجه شد",
|
||||||
|
"anErrorOccurred": "خطایی رخ داد"
|
||||||
|
},
|
||||||
|
|
||||||
|
"active": {
|
||||||
|
"activeDevices": "نشست های فعال",
|
||||||
|
"activeDevicesCaption": "مشاهده و مدیریت تمام نشست های فعال شما",
|
||||||
|
"deleteDevicesButton": "حذف بقیه نشست ها",
|
||||||
|
"deleteDevice": "حذف نشست",
|
||||||
|
"currentDevice": "دستگاه فعلی",
|
||||||
|
"minutesAgo": "{{count}} دقیقه پیش",
|
||||||
|
"justNow": "همین الان",
|
||||||
|
"notLoggedIn": "شما وارد سیستم نشدهاید",
|
||||||
|
"failFetchActiveSessions": "دریافت نشست های فعال ناموفق بود.",
|
||||||
|
"errorFetch": "هنگام دریافت جلسات فعال شما خطایی روی داد."
|
||||||
|
},
|
||||||
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "تنظیمات پایه",
|
"title": "تنظیمات پایه",
|
||||||
"description": "تنظیمات پایهای حساب خود را تغییر دهید",
|
"description": "تنظیمات پایهای حساب خود را تغییر دهید",
|
||||||
@@ -13,6 +91,36 @@
|
|||||||
"solar": "شمسی",
|
"solar": "شمسی",
|
||||||
"lunar": "قمری",
|
"lunar": "قمری",
|
||||||
"christian": "میلادی",
|
"christian": "میلادی",
|
||||||
"iran": "ایران"
|
"iran": "ایران",
|
||||||
|
"saving": "در حال ذخیرهسازی...",
|
||||||
|
"notLoggedIn": "شما وارد سیستم نشدهاید",
|
||||||
|
"failedRetrieve": "بازیابی تنظیمات ناموفق بود.",
|
||||||
|
"errorFetch": "هنگام دریافت تنظیمات شما خطایی روی داد.",
|
||||||
|
"saveFailed": "خطا در ذخیره",
|
||||||
|
"invalidSelection": "انتخاب نامعتبر است"
|
||||||
|
},
|
||||||
|
|
||||||
|
"securityForm": {
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"determinePassword": "با تعیین یک رمز عبور قوی راحت تر به اکانت هارمونی خود وارد شوید",
|
||||||
|
"addPassword": "افزودن رمز عبور",
|
||||||
|
"notDeterminedPassword": "هنوز رمز عبوری برای این حساب کاربری تعیین نکرده اید",
|
||||||
|
"newPassword": "رمز عبور جدید",
|
||||||
|
"confirmPassword": "تکرار رمز عبور",
|
||||||
|
"confirm": "تایید",
|
||||||
|
"hasNumber": "شامل عدد",
|
||||||
|
"hasMinLength": "حداقل 8 کاراکتر",
|
||||||
|
"hasUpperAndLower": "شامل یک حرف کوچک و بزرگ",
|
||||||
|
"hasSpecialChar": "شامل علامت (!@#$%^&*)",
|
||||||
|
"notCompatibility": "تکرار رمز عبور با رمز عبور یکسان نمی باشد",
|
||||||
|
"alertSuccess": "رمز عبور با موفقیت تعویض شد",
|
||||||
|
"lastChange": "آخرین تغییر چند ثانیه پیش",
|
||||||
|
"activePassword": "رمز عبور فعال است",
|
||||||
|
"recentLogins": "ورود های اخیر",
|
||||||
|
"description": "در این بخش از ورود های اخیر به اکانت هارمونی خود را مشاهده می کنید",
|
||||||
|
"currentDevice": "دستگاه فعلی",
|
||||||
|
"changePassword": "تغییر رمز عبور",
|
||||||
|
"currentPassword": "رمز عبور فعلی",
|
||||||
|
"forgetPassword": "رمز عبور را فراموش کرده اید؟"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ export function CardContainer({
|
|||||||
sx={{
|
sx={{
|
||||||
marginInline: 'auto',
|
marginInline: 'auto',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 'min(100%, 818px)',
|
|
||||||
// paddingInline: { xs: 2, sm: 3, md: 4 },
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toLocaleDigits } from '@/utils/persianDigit';
|
||||||
|
|
||||||
interface CountdownTimerProps {
|
interface CountdownTimerProps {
|
||||||
initialSeconds: number;
|
initialSeconds: number;
|
||||||
@@ -9,30 +11,30 @@ export function CountDownTimer({
|
|||||||
initialSeconds,
|
initialSeconds,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: CountdownTimerProps) {
|
}: CountdownTimerProps) {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
const [secondsLeft, setSecondsLeft] = useState(initialSeconds);
|
const [secondsLeft, setSecondsLeft] = useState(initialSeconds);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSecondsLeft(initialSeconds);
|
setSecondsLeft(initialSeconds);
|
||||||
}, [initialSeconds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (secondsLeft <= 0) {
|
|
||||||
onComplete?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setSecondsLeft((prev) => prev - 1);
|
setSecondsLeft((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
onComplete?.();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [secondsLeft, onComplete]);
|
|
||||||
|
|
||||||
const toPersianDigits = (str: string) =>
|
return () => clearInterval(timer);
|
||||||
str.replace(/\d/g, (d: string) => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
|
}, [initialSeconds, onComplete]);
|
||||||
|
|
||||||
const formatTime = (totalSeconds: number) => {
|
const formatTime = (totalSeconds: number) => {
|
||||||
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
|
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
|
||||||
const seconds = String(totalSeconds % 60).padStart(2, '0');
|
const seconds = String(totalSeconds % 60).padStart(2, '0');
|
||||||
return toPersianDigits(`${minutes}:${seconds}`);
|
return toLocaleDigits(`${minutes}:${seconds}`, i18n.language);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <span>{formatTime(secondsLeft)}</span>;
|
return <span>{formatTime(secondsLeft)}</span>;
|
||||||
|
|||||||
@@ -20,13 +20,18 @@ export function CountryFlag({ code }: CountryFlagProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<img
|
<Box
|
||||||
|
component="img"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={flagUrl}
|
src={flagUrl}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
width="24"
|
sx={{
|
||||||
height="16"
|
width: 24,
|
||||||
style={{ borderRadius: '2px', border: '1px solid #ccc' }}
|
height: 16,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2">{displayName}</Typography>
|
<Typography variant="body2">{displayName}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { ToggleButtonGroup, ToggleButton, Box } from '@mui/material';
|
import { ToggleButtonGroup, ToggleButton, Box } from '@mui/material';
|
||||||
import { useColorScheme } from '@mui/material/styles';
|
|
||||||
import { Sun1, Moon } from 'iconsax-react';
|
import { Sun1, Moon } from 'iconsax-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Icon } from '@rkheftan/harmony-ui';
|
import { Icon } from '@rkheftan/harmony-ui';
|
||||||
import { type MouseEvent } from 'react';
|
import { type MouseEvent } from 'react';
|
||||||
|
|
||||||
export const ThemeToggleButton = () => {
|
enum ThemeMode {
|
||||||
const { mode, setMode } = useColorScheme();
|
Light = 'light',
|
||||||
|
Dark = 'dark',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeToggleButtonProps {
|
||||||
|
value: 'light' | 'dark';
|
||||||
|
onChange: (newMode: 'light' | 'dark') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeToggleButton = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: ThemeToggleButtonProps) => {
|
||||||
const { t } = useTranslation('setting');
|
const { t } = useTranslation('setting');
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
@@ -14,26 +25,26 @@ export const ThemeToggleButton = () => {
|
|||||||
newMode: 'light' | 'dark' | null,
|
newMode: 'light' | 'dark' | null,
|
||||||
) => {
|
) => {
|
||||||
if (newMode !== null) {
|
if (newMode !== null) {
|
||||||
setMode(newMode);
|
onChange(newMode);
|
||||||
localStorage.setItem('theme', newMode);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box dir="rtl">
|
<Box>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={mode}
|
value={value}
|
||||||
exclusive
|
exclusive
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '12px',
|
borderRadius: 1.5,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
value="light"
|
value={ThemeMode.Light}
|
||||||
|
aria-label="light theme"
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -51,7 +62,8 @@ export const ThemeToggleButton = () => {
|
|||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
value="dark"
|
value={ThemeMode.Dark}
|
||||||
|
aria-label="dark theme"
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Alert, Snackbar, type AlertColor } from '@mui/material';
|
|||||||
|
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
|
||||||
export interface ToastProps extends PropsWithChildren {
|
export interface ToastProps extends PropsWithChildren {
|
||||||
color: AlertColor | undefined;
|
color: AlertColor | undefined;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
import {
|
|
||||||
TextField,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
Box,
|
|
||||||
type SelectChangeEvent,
|
|
||||||
Switch,
|
|
||||||
FormGroup,
|
|
||||||
Button,
|
|
||||||
Typography,
|
|
||||||
Link,
|
|
||||||
} from '@mui/material';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function UserCompletionForm() {
|
|
||||||
const [sex, setSex] = useState('');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showEmail, setShowEmail] = useState(false);
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [codeSent, setCodeSent] = useState(false);
|
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
|
||||||
const [buttonState, setButtonState] = useState('default'); // default | counting | sent
|
|
||||||
const [countdown, setCountdown] = useState(60);
|
|
||||||
const matchPassword = password === confirmPassword;
|
|
||||||
const hasNumber = /\d/.test(password);
|
|
||||||
const hasMinLength = password.length >= 8;
|
|
||||||
const hasUpperAndLower = /[A-Z]/.test(password) && /[a-z]/.test(password);
|
|
||||||
const hasSpecialChar = /[!@#$%^&*]/.test(password);
|
|
||||||
const correctEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
||||||
|
|
||||||
const handleTogglePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setShowPassword(e.target.checked);
|
|
||||||
};
|
|
||||||
const handleToggleEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setShowEmail(e.target.checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e: SelectChangeEvent) => {
|
|
||||||
setSex(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClickCodeSent = () => {
|
|
||||||
setCodeSent(true);
|
|
||||||
setButtonState('sent');
|
|
||||||
setTimeout(() => {
|
|
||||||
setButtonState('counting');
|
|
||||||
setCountdown(60);
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timer: ReturnType<typeof setInterval>;
|
|
||||||
if (buttonState === 'counting' && countdown > 0) {
|
|
||||||
timer = setInterval(() => {
|
|
||||||
setCountdown((prev) => prev - 1);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
if (countdown === 0) {
|
|
||||||
setButtonState('default');
|
|
||||||
}
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [buttonState, countdown]);
|
|
||||||
|
|
||||||
const toPersianDigits = (str: string) =>
|
|
||||||
str.replace(/\d/g, (d: string) => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
|
|
||||||
|
|
||||||
const getButtonLabel = () => {
|
|
||||||
if (buttonState === 'sent') return 'ارسال شد!';
|
|
||||||
if (buttonState === 'counting') {
|
|
||||||
const minutes = String(Math.floor(countdown / 60)).padStart(2, '0');
|
|
||||||
const seconds = String(countdown % 60).padStart(2, '0');
|
|
||||||
const time = `${minutes}:${seconds}`;
|
|
||||||
return toPersianDigits(time);
|
|
||||||
}
|
|
||||||
return 'ارسال کد تایید';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
dir="rtl"
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: '500px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: 2,
|
|
||||||
padding: 5,
|
|
||||||
margin: '40px auto',
|
|
||||||
boxShadow: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flexDirection: 'column', mb: 2 }}>
|
|
||||||
<Typography variant="h5" sx={{ mb: 0.5 }}>
|
|
||||||
تکمیل اطلاعات حساب کاربری
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ color: 'gray', fontSize: '14px' }}>
|
|
||||||
اطلاعات کسب و کار خود را وارد کنید
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label="نام"
|
|
||||||
placeholder="نام"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
width: '330px',
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
height: 45,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="نام خانوادگی"
|
|
||||||
placeholder="نام خانوادگی"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
width: '330px',
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
height: 45,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
||||||
<FormControl sx={{ width: '330px' }}>
|
|
||||||
<InputLabel id="sex-label">جنسیت</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="sex-label"
|
|
||||||
id="sex"
|
|
||||||
value={sex}
|
|
||||||
label="جنسیت"
|
|
||||||
onChange={handleChange}
|
|
||||||
sx={{
|
|
||||||
height: '45px',
|
|
||||||
'& .MuiSelect-select': {
|
|
||||||
paddingY: '10px',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value="female">زن</MenuItem>
|
|
||||||
<MenuItem value="male">مرد</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="کدملی(اختیاری)"
|
|
||||||
placeholder="کدملی(اختیاری)"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
width: '330px',
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
height: 45,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<FormGroup>
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
|
||||||
<Switch
|
|
||||||
checked={showPassword}
|
|
||||||
onChange={handleTogglePassword}
|
|
||||||
sx={{
|
|
||||||
transform: 'scaleX(-1)',
|
|
||||||
'& .MuiSwitch-thumb': {
|
|
||||||
transform: 'scaleX(-1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography> تعیین رمز عبور</Typography>
|
|
||||||
</Box>
|
|
||||||
</FormGroup>
|
|
||||||
{showPassword && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', width: '330px' }}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label="رمز عبور"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
height: 45,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{password && (
|
|
||||||
<Box sx={{ mt: 1 }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: hasNumber ? 'green' : 'red' }}
|
|
||||||
>
|
|
||||||
شامل عدد
|
|
||||||
</Typography>
|
|
||||||
<br />
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: hasMinLength ? 'green' : 'red' }}
|
|
||||||
>
|
|
||||||
حداقل 8 کاراکتر
|
|
||||||
</Typography>
|
|
||||||
<br />
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: hasUpperAndLower ? 'green' : 'red' }}
|
|
||||||
>
|
|
||||||
شامل یک حرف بزرگ و کوچک
|
|
||||||
</Typography>
|
|
||||||
<br />
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: hasSpecialChar ? 'green' : 'red' }}
|
|
||||||
>
|
|
||||||
شامل علامت(!@#$%^&*)
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{showPassword && (
|
|
||||||
<TextField
|
|
||||||
label="تکرار رمز عبور"
|
|
||||||
variant="outlined"
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
error={confirmPassword.length > 0 && !matchPassword}
|
|
||||||
helperText={
|
|
||||||
confirmPassword.length > 0 && !matchPassword
|
|
||||||
? 'مطابقت ندارد'
|
|
||||||
: ' '
|
|
||||||
}
|
|
||||||
sx={{
|
|
||||||
width: '330px',
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
height: 45,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
|
||||||
<Switch
|
|
||||||
checked={showEmail}
|
|
||||||
onChange={handleToggleEmail}
|
|
||||||
sx={{
|
|
||||||
transform: 'scaleX(-1)',
|
|
||||||
'& .MuiSwitch-thumb': {
|
|
||||||
transform: 'scaleX(-1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography> اتصال ایمیل خود</Typography>
|
|
||||||
</Box>
|
|
||||||
</FormGroup>
|
|
||||||
{showEmail && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<TextField
|
|
||||||
label="ایمیل"
|
|
||||||
variant="outlined"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
width: '330px',
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
height: 45,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{email && (
|
|
||||||
<Typography sx={{ color: correctEmail ? 'green' : 'red' }}>
|
|
||||||
فرم درست ایمیل وارد کنید
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
onClick={onClickCodeSent}
|
|
||||||
sx={{ width: '200px' }}
|
|
||||||
disabled={buttonState === 'sent' || buttonState === 'counting'}
|
|
||||||
>
|
|
||||||
{getButtonLabel()}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{codeSent && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
||||||
<TextField
|
|
||||||
label="کد تایید"
|
|
||||||
variant="outlined"
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={(e) => setVerificationCode(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
width: '330px',
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
height: 45,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
sx={{
|
|
||||||
width: '150px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: 0.5,
|
|
||||||
borderColor: '#1976d2',
|
|
||||||
color: '#1976d2',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
بررسی کد
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
|
||||||
ادامه فرایند ثبت نام به منزله تایید و قبول{' '}
|
|
||||||
<Link href="" target="_blank" rel="">
|
|
||||||
قوانین و مقررات هارمونی
|
|
||||||
</Link>{' '}
|
|
||||||
می باشد.
|
|
||||||
</Typography>
|
|
||||||
<Button variant="contained" sx={{ width: '250px', height: '40px' }}>
|
|
||||||
تایید و ثبت نام
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ export function PageWrapper({ children }: PageWrapperProps) {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
mx: 'auto',
|
mx: 'auto',
|
||||||
width: { xs: '100%', sm: '754px' },
|
|
||||||
backgroundColor: 'background.paper',
|
backgroundColor: 'background.paper',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -1,53 +1,205 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
useTheme,
|
useTheme,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { DeviceMessage, Logout } from 'iconsax-react';
|
import { DeviceMessage, Logout } from 'iconsax-react';
|
||||||
import { CardContainer } from '@/components/CardContainer';
|
import { CardContainer } from '@/components/CardContainer';
|
||||||
import { PageWrapper } from '../PageWrapper';
|
import { PageWrapper } from '../PageWrapper';
|
||||||
import React from 'react';
|
|
||||||
import { Icon } from '@rkheftan/harmony-ui';
|
import { Icon } from '@rkheftan/harmony-ui';
|
||||||
|
import apiClient from '@/lib/apiClient';
|
||||||
|
|
||||||
|
function formatSessionDate(
|
||||||
|
isoDate: string,
|
||||||
|
lang: string,
|
||||||
|
t: TFunction,
|
||||||
|
): string {
|
||||||
|
const date = new Date(isoDate);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInMinutes = Math.floor(
|
||||||
|
(now.getTime() - date.getTime()) / (1000 * 60),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (diffInMinutes < 1) {
|
||||||
|
return t('active.justNow');
|
||||||
|
}
|
||||||
|
if (diffInMinutes < 60) {
|
||||||
|
return t('active.minutesAgo', { count: diffInMinutes });
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayLocale: string;
|
||||||
|
let options: Intl.DateTimeFormatOptions;
|
||||||
|
|
||||||
|
if (lang.startsWith('fa')) {
|
||||||
|
displayLocale = 'fa-IR';
|
||||||
|
options = {
|
||||||
|
calendar: 'persian',
|
||||||
|
numberingSystem: 'arab',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
displayLocale = 'en-US';
|
||||||
|
options = {
|
||||||
|
calendar: 'gregory',
|
||||||
|
numberingSystem: 'latn',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(displayLocale, options).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
timeAndDate: string;
|
||||||
|
deviceModel: string;
|
||||||
|
ip: string;
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiSession {
|
||||||
|
key: string;
|
||||||
|
created: string;
|
||||||
|
deviceOs: string;
|
||||||
|
deviceName: string;
|
||||||
|
ipAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSessionsData {
|
||||||
|
sessions: ApiSession[];
|
||||||
|
currentKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
activeSessions?: ActiveSessionsData;
|
||||||
|
}
|
||||||
|
|
||||||
export function ActiveDevices() {
|
export function ActiveDevices() {
|
||||||
const { t } = useTranslation('activeDevices');
|
const { t, i18n } = useTranslation('setting');
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [loadingDeleteIds, setLoadingDeleteIds] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
const devices = [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
timeAndDate: 'دقایقی پیش',
|
|
||||||
deviceModel: 'asus i5 24i',
|
|
||||||
ip: '192.168.1.1',
|
|
||||||
current: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
timeAndDate: '۲۲:۱۳ - ۱۴۰۴/۰۹/۰۹',
|
|
||||||
deviceModel: 'Dell XPS 15',
|
|
||||||
ip: '89.165.23.12',
|
|
||||||
current: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
timeAndDate: '۲۲:۱۳ - ۱۴۰۴/۰۹/۰۹',
|
|
||||||
deviceModel: 'Samsung Galaxy S22',
|
|
||||||
ip: '10.0.0.5',
|
|
||||||
current: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
timeAndDate: '۲۲:۱۳ - ۱۴۰۴/۰۹/۰۹',
|
|
||||||
deviceModel: 'MacBook Pro 14-inch',
|
|
||||||
ip: '172.16.0.101',
|
|
||||||
current: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
|
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchActiveSessions = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFetchError(null);
|
||||||
|
if (!token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setFetchError(t('active.notLoggedIn'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<ProfileApiResponse>(
|
||||||
|
'/Profile/GetProfile',
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success && res.data.activeSessions) {
|
||||||
|
const { sessions, currentKey } = res.data.activeSessions;
|
||||||
|
const formattedDevices = sessions.map((session: ApiSession) => ({
|
||||||
|
id: session.key,
|
||||||
|
timeAndDate: formatSessionDate(session.created, i18n.language, t),
|
||||||
|
deviceModel: `${session.deviceOs} ${session.deviceName}`,
|
||||||
|
ip: session.ipAddress,
|
||||||
|
current: session.key === currentKey,
|
||||||
|
}));
|
||||||
|
setDevices(formattedDevices);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
res.data.message || t('active.failFetchActiveSessions'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(t('active.failFetchActiveSessions'), err);
|
||||||
|
let message = t('active.errorFetch');
|
||||||
|
if (err instanceof Error) {
|
||||||
|
message = err.message;
|
||||||
|
}
|
||||||
|
setFetchError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchActiveSessions();
|
||||||
|
}, [token, i18n.language, t]);
|
||||||
|
|
||||||
|
const handleDeleteDevice = async (id: string) => {
|
||||||
|
if (loadingDeleteIds.includes(id)) return;
|
||||||
|
setLoadingDeleteIds((prev) => [...prev, id]);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(
|
||||||
|
'/Profile/DeleteSessions',
|
||||||
|
{
|
||||||
|
keys: [id],
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id));
|
||||||
|
} else {
|
||||||
|
console.error('Delete failed:', res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// console.error('Delete error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingDeleteIds((prev) =>
|
||||||
|
prev.filter((loadingId) => loadingId !== id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminateAllOtherSessions = async () => {
|
||||||
|
const otherSessionKeys = devices.filter((d) => !d.current).map((d) => d.id);
|
||||||
|
|
||||||
|
if (otherSessionKeys.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(
|
||||||
|
'/Profile/DeleteSessions',
|
||||||
|
{
|
||||||
|
keys: otherSessionKeys,
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
setDevices((prev) => prev.filter((d) => d.current));
|
||||||
|
} else {
|
||||||
|
console.error('Failed to terminate other sessions:', res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error terminating sessions:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper>
|
<PageWrapper>
|
||||||
<CardContainer
|
<CardContainer
|
||||||
@@ -57,151 +209,181 @@ export function ActiveDevices() {
|
|||||||
<Button
|
<Button
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
onClick={handleTerminateAllOtherSessions}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
borderColor: 'error.main',
|
borderColor: 'error.main',
|
||||||
color: 'error.main',
|
color: 'error.main',
|
||||||
}}
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{t('active.deletDevicesButton')}
|
{t('active.deleteDevicesButton')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box
|
{isLoading ? (
|
||||||
sx={{
|
<Box
|
||||||
px: { xs: 2, sm: 3, md: 4 },
|
sx={{
|
||||||
py: 2,
|
display: 'flex',
|
||||||
display: 'flex',
|
justifyContent: 'center',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
// gap: 2,
|
p: 4,
|
||||||
}}
|
minHeight: '200px',
|
||||||
>
|
}}
|
||||||
{devices.map((device) => (
|
>
|
||||||
<React.Fragment key={device.id}>
|
<CircularProgress />
|
||||||
<Box
|
</Box>
|
||||||
sx={{
|
) : fetchError ? (
|
||||||
display: 'flex',
|
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
<Typography color="error.main">{fetchError}</Typography>
|
||||||
alignItems: { xs: 'flex-start', sm: 'center' },
|
</Box>
|
||||||
minHeight: 50,
|
) : (
|
||||||
}}
|
<Box
|
||||||
>
|
sx={{
|
||||||
<Typography
|
px: { xs: 2, sm: 3, md: 4 },
|
||||||
variant="body2"
|
py: 2,
|
||||||
sx={{
|
display: 'flex',
|
||||||
flexBasis: { xs: '100%', sm: 'auto' },
|
flexDirection: 'column',
|
||||||
mb: { xs: 1, sm: 0 },
|
}}
|
||||||
minWidth: { sm: '138px' },
|
>
|
||||||
order: { xs: 1, sm: 1 },
|
{devices.map((device) => (
|
||||||
}}
|
<React.Fragment key={device.id}>
|
||||||
>
|
|
||||||
{device.timeAndDate}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
gap: 1,
|
alignItems: { xs: 'flex-start', sm: 'center' },
|
||||||
flexBasis: { xs: '100%', sm: 'auto' },
|
minHeight: 50,
|
||||||
mb: { xs: 1, sm: 0 },
|
py: 1.5,
|
||||||
minWidth: { sm: '138px' },
|
|
||||||
order: { xs: 2, sm: 2 },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon
|
<Typography
|
||||||
Component={DeviceMessage}
|
variant="body2"
|
||||||
size="medium"
|
|
||||||
color="primary.main"
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" noWrap>
|
|
||||||
{device.deviceModel}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
flexBasis: { xs: '100%', sm: 'auto' },
|
|
||||||
mb: { xs: 1, sm: 0 },
|
|
||||||
minWidth: { sm: '138px' },
|
|
||||||
order: { xs: 3, sm: 3 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{device.ip}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexBasis: { xs: '100%', sm: 'auto' },
|
|
||||||
mb: { xs: 1, sm: 0 },
|
|
||||||
minWidth: { sm: '138px' },
|
|
||||||
order: { xs: 4, sm: 4 },
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{device.current && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="medium"
|
|
||||||
sx={{
|
|
||||||
borderRadius: '100px',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'success.main',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
color: 'success.main',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('active.currentDevice')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexBasis: { xs: '100%', sm: 'auto' },
|
|
||||||
mb: { xs: 1, sm: 0 },
|
|
||||||
textAlign: { xs: 'left', sm: 'center' },
|
|
||||||
minWidth: { sm: '138px' },
|
|
||||||
order: { xs: 5, sm: 5 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={
|
|
||||||
<Icon
|
|
||||||
Component={Logout}
|
|
||||||
size="small"
|
|
||||||
color="error.main"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
disabled={device.current}
|
|
||||||
sx={{
|
sx={{
|
||||||
color: 'error.main',
|
flexBasis: { xs: '100%', sm: 'auto' },
|
||||||
borderRadius: 1,
|
mb: { xs: 1, sm: 0 },
|
||||||
borderColor: 'error.main',
|
minWidth: { sm: '138px' },
|
||||||
display: 'flex',
|
// order: { xs: 1, sm: 1 },
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
'& .MuiButton-startIcon': {
|
|
||||||
marginRight: '4px',
|
|
||||||
marginLeft: 0,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('active.deleteDevice')}
|
{device.timeAndDate}
|
||||||
</Button>
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexBasis: { xs: '100%', sm: 'auto' },
|
||||||
|
mb: { xs: 1, sm: 0 },
|
||||||
|
minWidth: { sm: '138px' },
|
||||||
|
order: { xs: 2, sm: 2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
Component={DeviceMessage}
|
||||||
|
size="medium"
|
||||||
|
color="primary.main"
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" noWrap>
|
||||||
|
{device.deviceModel}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
flexBasis: { xs: '100%', sm: 'auto' },
|
||||||
|
mb: { xs: 1, sm: 0 },
|
||||||
|
minWidth: { sm: '138px' },
|
||||||
|
order: { xs: 3, sm: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.ip}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexBasis: { xs: '100%', sm: 'auto' },
|
||||||
|
mb: { xs: 1, sm: 0 },
|
||||||
|
minWidth: { sm: '138px' },
|
||||||
|
order: { xs: 4, sm: 4 },
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.current && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="medium"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 12.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'success.main',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: 'success.main',
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
color: 'success.main',
|
||||||
|
borderColor: 'success.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{t('active.currentDevice')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexBasis: { xs: '100%', sm: 'auto' },
|
||||||
|
mb: { xs: 1, sm: 0 },
|
||||||
|
textAlign: { xs: 'left', sm: 'center' },
|
||||||
|
minWidth: { sm: '138px' },
|
||||||
|
order: { xs: 5, sm: 5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={
|
||||||
|
<Icon
|
||||||
|
Component={Logout}
|
||||||
|
size="small"
|
||||||
|
color="error.main"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
device.current || loadingDeleteIds.includes(device.id)
|
||||||
|
}
|
||||||
|
onClick={() => handleDeleteDevice(device.id)}
|
||||||
|
sx={{
|
||||||
|
color: 'error.main',
|
||||||
|
borderRadius: 1,
|
||||||
|
borderColor: 'error.main',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
'& .MuiButton-startIcon': {
|
||||||
|
marginRight: 0.5,
|
||||||
|
marginLeft: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingDeleteIds.includes(device.id)
|
||||||
|
? t('active.deleting...')
|
||||||
|
: t('active.deleteDevice')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
{isXsup && (
|
||||||
{isXsup && (
|
<Box sx={{ color: 'divider', borderBottom: '1px solid' }} />
|
||||||
<Box sx={{ color: 'divider', borderBottom: '1px solid' }} />
|
)}
|
||||||
)}
|
</React.Fragment>
|
||||||
</React.Fragment>
|
))}
|
||||||
))}
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import { CardContainer } from '@/components/CardContainer';
|
|||||||
import { PageWrapper } from '../PageWrapper';
|
import { PageWrapper } from '../PageWrapper';
|
||||||
import { PasswordDialog } from './PasswordDialog';
|
import { PasswordDialog } from './PasswordDialog';
|
||||||
import { Toast } from '@/components/Toast';
|
import { Toast } from '@/components/Toast';
|
||||||
|
import { regex } from '@/utils/regex';
|
||||||
|
|
||||||
export function PasswordSecurity() {
|
export function PasswordSecurity() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const { t } = useTranslation('security');
|
const { t } = useTranslation('setting');
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -26,12 +27,14 @@ export function PasswordSecurity() {
|
|||||||
const [changePassword, setChangePassword] = useState(false);
|
const [changePassword, setChangePassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const hasNumber = /\d/.test(password);
|
const {
|
||||||
const hasMinLength = password.length >= 8;
|
hasNumber,
|
||||||
const hasUpperAndLower = /[A-Z]/.test(password) && /[a-z]/.test(password);
|
hasMinLength,
|
||||||
const hasSpecialChar = /[!@#$%^&*]/.test(password);
|
hasUpperAndLower,
|
||||||
const validPassword =
|
hasSpecialChar,
|
||||||
hasNumber && hasMinLength && hasUpperAndLower && hasSpecialChar;
|
validPassword,
|
||||||
|
} = regex(password);
|
||||||
|
|
||||||
const matchPassword = password === confirmPassword;
|
const matchPassword = password === confirmPassword;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PageWrapper } from '../PageWrapper';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export function RecentLogins() {
|
export function RecentLogins() {
|
||||||
const { t } = useTranslation('security');
|
const { t } = useTranslation('setting');
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -31,7 +31,6 @@ export function RecentLogins() {
|
|||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
// px: { xs: 2, sm: 3, md: 4 },
|
|
||||||
py: 2,
|
py: 2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -102,7 +101,7 @@ export function RecentLogins() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '15px',
|
borderRadius: 2,
|
||||||
border: '2px solid',
|
border: '2px solid',
|
||||||
borderColor: 'success.main',
|
borderColor: 'success.main',
|
||||||
height: '30px',
|
height: '30px',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useColorScheme,
|
useColorScheme,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
TextField,
|
TextField,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { CardContainer } from '@/components/CardContainer';
|
import { CardContainer } from '@/components/CardContainer';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -13,69 +14,188 @@ import { ThemeToggleButton } from '@/components/ThemToggle';
|
|||||||
import { PageWrapper } from '../PageWrapper';
|
import { PageWrapper } from '../PageWrapper';
|
||||||
import { Icon } from '@rkheftan/harmony-ui';
|
import { Icon } from '@rkheftan/harmony-ui';
|
||||||
import { Sun1, Moon, Calendar1 } from 'iconsax-react';
|
import { Sun1, Moon, Calendar1 } from 'iconsax-react';
|
||||||
|
import apiClient from '@/lib/apiClient';
|
||||||
|
|
||||||
|
type ThemeMode = 'light' | 'dark';
|
||||||
|
type CalendarType = 'christian' | 'solar' | 'lunar';
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
language: string;
|
||||||
|
calendar: CalendarType;
|
||||||
|
theme: ThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSettingsFromApi {
|
||||||
|
theme: number;
|
||||||
|
calendarType: number;
|
||||||
|
language: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetProfileApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
userSettings?: UserSettingsFromApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveSettingApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
{ code: 'en', label: 'English', apiValue: 1 },
|
||||||
|
{ code: 'fa', label: 'فارسی', apiValue: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const calendarOptions: { key: CalendarType; apiValue: number }[] = [
|
||||||
|
{ key: 'christian', apiValue: 1 },
|
||||||
|
{ key: 'solar', apiValue: 2 },
|
||||||
|
{ key: 'lunar', apiValue: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const themeApiMap: Record<ThemeMode, number> = { light: 1, dark: 2 };
|
||||||
|
|
||||||
export function Setting() {
|
export function Setting() {
|
||||||
const { t, i18n } = useTranslation(['setting']);
|
const { t, i18n } = useTranslation(['setting']);
|
||||||
const { mode } = useColorScheme();
|
const { mode, setMode } = useColorScheme();
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
|
||||||
const [savedLanguage, setSavedLanguage] = useState<string>(
|
const [savedSettings, setSavedSettings] = useState<SettingsState>({
|
||||||
i18n.language || 'en',
|
language: i18n.language || 'en',
|
||||||
);
|
calendar: 'solar',
|
||||||
const [draftLanguage, setDraftLanguage] = useState<string>(savedLanguage);
|
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [draftSettings, setDraftSettings] =
|
||||||
|
useState<SettingsState>(savedSettings);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [selectedCalendar, setSelectedCalendar] = useState<
|
const [loading, setLoading] = useState(false);
|
||||||
'christian' | 'solar' | 'lunar'
|
const [error, setError] = useState<string | null>(null);
|
||||||
>('solar');
|
|
||||||
|
|
||||||
const languageOptions = [
|
const [isFetching, setIsFetching] = useState(true);
|
||||||
{ code: 'en', label: 'English' },
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
{ code: 'fa', label: 'فارسی' },
|
|
||||||
];
|
|
||||||
const calendarOptions: ('christian' | 'solar' | 'lunar')[] = [
|
|
||||||
'christian',
|
|
||||||
'solar',
|
|
||||||
'lunar',
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleDraftLanguageChange = (
|
useEffect(() => {
|
||||||
_: React.SyntheticEvent,
|
const fetchUserSettings = async () => {
|
||||||
v: { code: string; label: string } | null,
|
setIsFetching(true);
|
||||||
) => v && setDraftLanguage(v.code);
|
setFetchError(null);
|
||||||
|
if (!token) {
|
||||||
|
setIsFetching(false);
|
||||||
|
setFetchError(t('settings.notLoggedIn'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<GetProfileApiResponse>(
|
||||||
|
'/Profile/GetProfile',
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success && res.data.userSettings) {
|
||||||
|
const { theme, calendarType, language } = res.data.userSettings;
|
||||||
|
const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
|
||||||
|
1: 'light',
|
||||||
|
2: 'dark',
|
||||||
|
};
|
||||||
|
const themeMode = themeReverseMap[theme] || 'light';
|
||||||
|
const calendarSetting = calendarOptions.find(
|
||||||
|
(c) => c.apiValue === calendarType,
|
||||||
|
);
|
||||||
|
const calendarKey = calendarSetting ? calendarSetting.key : 'solar';
|
||||||
|
const languageSetting = languageOptions.find(
|
||||||
|
(l) => l.apiValue === language,
|
||||||
|
);
|
||||||
|
const languageCode = languageSetting ? languageSetting.code : 'en';
|
||||||
|
const newSettings: SettingsState = {
|
||||||
|
theme: themeMode,
|
||||||
|
calendar: calendarKey,
|
||||||
|
language: languageCode,
|
||||||
|
};
|
||||||
|
setSavedSettings(newSettings);
|
||||||
|
setDraftSettings(newSettings);
|
||||||
|
setMode(themeMode);
|
||||||
|
i18n.changeLanguage(languageCode);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.data.message || t('settings.failRetrieve'));
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
let message = t('settings.errorFetch');
|
||||||
|
if (e instanceof Error) {
|
||||||
|
message = e.message;
|
||||||
|
}
|
||||||
|
setFetchError(message);
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchUserSettings();
|
||||||
|
}, [token, setMode, i18n, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setDraftSettings({
|
||||||
|
...savedSettings,
|
||||||
|
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isEditing, savedSettings, mode]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setDraftLanguage(savedLanguage);
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
if (draftLanguage !== savedLanguage) {
|
setError(null);
|
||||||
i18n.changeLanguage(draftLanguage);
|
setLoading(true);
|
||||||
setSavedLanguage(draftLanguage);
|
try {
|
||||||
|
const languageObj = languageOptions.find(
|
||||||
|
(o) => o.code === draftSettings.language,
|
||||||
|
);
|
||||||
|
const calendarObj = calendarOptions.find(
|
||||||
|
(c) => c.key === draftSettings.calendar,
|
||||||
|
);
|
||||||
|
const apiThemeValue = themeApiMap[draftSettings.theme];
|
||||||
|
|
||||||
|
if (!languageObj || !calendarObj) {
|
||||||
|
setError(t('settings.invalidSelection'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post<SaveSettingApiResponse>(
|
||||||
|
'/Profile/SaveSetting',
|
||||||
|
{
|
||||||
|
theme: apiThemeValue,
|
||||||
|
calendarType: calendarObj.apiValue,
|
||||||
|
language: languageObj.apiValue,
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
setMode(draftSettings.theme);
|
||||||
|
setSavedSettings(draftSettings);
|
||||||
|
await i18n.changeLanguage(draftSettings.language);
|
||||||
|
setIsEditing(false);
|
||||||
|
} else {
|
||||||
|
setError(res.data.message || t('settings.saveFailed'));
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(t('settings.saveFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditToggle = () =>
|
const handleEditToggle = () => {
|
||||||
isEditing ? handleSave() : setIsEditing(true);
|
isEditing ? handleSave() : setIsEditing(true);
|
||||||
|
};
|
||||||
// useEffect(() => {
|
|
||||||
// setSelectedCalendar(t('settings.solar'));
|
|
||||||
// }, [i18n.language, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper>
|
<PageWrapper>
|
||||||
<Box
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
sx={{
|
<Box sx={{ px: { xs: 0, sm: 3 }, mx: 0 }}>
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: { xs: 0, sm: 3 },
|
|
||||||
mx: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardContainer
|
<CardContainer
|
||||||
title={t('settings.title')}
|
title={t('settings.title')}
|
||||||
subtitle={t('settings.description')}
|
subtitle={t('settings.description')}
|
||||||
@@ -90,9 +210,9 @@ export function Setting() {
|
|||||||
sx={{
|
sx={{
|
||||||
color: 'primary.main',
|
color: 'primary.main',
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
// width: { xs: '100%', sm: 'auto' },
|
|
||||||
fontSize: { xs: '0.85rem', sm: '1rem' },
|
fontSize: { xs: '0.85rem', sm: '1rem' },
|
||||||
}}
|
}}
|
||||||
|
disabled={loading || isFetching}
|
||||||
>
|
>
|
||||||
{t('settings.rejectButton')}
|
{t('settings.rejectButton')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -108,80 +228,153 @@ export function Setting() {
|
|||||||
bgcolor: isEditing ? 'primary.main' : 'background.default',
|
bgcolor: isEditing ? 'primary.main' : 'background.default',
|
||||||
color: isEditing ? 'primary.contrastText' : 'primary.main',
|
color: isEditing ? 'primary.contrastText' : 'primary.main',
|
||||||
}}
|
}}
|
||||||
|
disabled={loading || isFetching}
|
||||||
>
|
>
|
||||||
{isEditing
|
{loading
|
||||||
? t('settings.saveButton')
|
? t('settings.saving...')
|
||||||
: t('settings.editButton')}
|
: isEditing
|
||||||
|
? t('settings.saveButton')
|
||||||
|
: t('settings.editButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box
|
{isFetching ? (
|
||||||
sx={{
|
|
||||||
px: { xs: 2, sm: 3, md: 4 },
|
|
||||||
py: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
justifyContent: 'center',
|
||||||
gap: 2,
|
alignItems: 'center',
|
||||||
mt: 2,
|
p: 4,
|
||||||
|
minHeight: '200px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ flex: 1 }}>
|
<CircularProgress />
|
||||||
{isEditing ? (
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
) : fetchError ? (
|
||||||
<Typography variant="body1">
|
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||||
{t('settings.theme')}
|
<Typography color="error.main">{fetchError}</Typography>
|
||||||
</Typography>
|
</Box>
|
||||||
<ThemeToggleButton />
|
) : (
|
||||||
</Box>
|
<Box
|
||||||
) : (
|
sx={{
|
||||||
<Box>
|
px: { xs: 2, sm: 3, md: 4 },
|
||||||
<Typography variant="caption" color="text.secondary">
|
py: 2,
|
||||||
{t('settings.theme')}
|
display: 'flex',
|
||||||
</Typography>
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Typography color="error.main" variant="body2" mb={2}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
gap: 2,
|
||||||
|
mt: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
{isEditing ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||||
Component={mode === 'light' ? Sun1 : Moon}
|
{t('settings.theme')}
|
||||||
size="medium"
|
</Typography>
|
||||||
variant="Bold"
|
<ThemeToggleButton
|
||||||
color={mode === 'light' ? 'black' : 'primary.main'}
|
value={draftSettings.theme}
|
||||||
|
onChange={(newTheme) => {
|
||||||
|
setDraftSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
theme: newTheme,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t('settings.theme')}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
Component={
|
||||||
|
savedSettings.theme === 'light' ? Sun1 : Moon
|
||||||
|
}
|
||||||
|
size="medium"
|
||||||
|
variant="Bold"
|
||||||
|
color={
|
||||||
|
savedSettings.theme === 'light'
|
||||||
|
? 'action.hover'
|
||||||
|
: 'primary.main'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{t(`settings.${savedSettings.theme}`)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<Autocomplete
|
||||||
|
options={languageOptions}
|
||||||
|
getOptionLabel={(o) => o.label}
|
||||||
|
value={
|
||||||
|
languageOptions.find(
|
||||||
|
(o) => o.code === draftSettings.language,
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
onChange={(_, v) =>
|
||||||
|
v &&
|
||||||
|
setDraftSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
language: v.code,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
renderInput={(p) => (
|
||||||
|
<TextField {...p} label={t('settings.language')} />
|
||||||
|
)}
|
||||||
|
size="medium"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t('settings.language')}
|
||||||
|
</Typography>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
{mode === 'light'
|
{
|
||||||
? t('settings.light')
|
languageOptions.find(
|
||||||
: t('settings.dark')}
|
(o) => o.code === savedSettings.language,
|
||||||
|
)?.label
|
||||||
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ mt: 2, width: { xs: '100%', md: '50%' } }}>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={languageOptions}
|
options={calendarOptions.map((c) => c.key)}
|
||||||
getOptionLabel={(o) => o.label}
|
getOptionLabel={(key) => t(`settings.${key}`)}
|
||||||
value={
|
value={draftSettings.calendar}
|
||||||
languageOptions.find((o) => o.code === draftLanguage) ||
|
onChange={(_, v) =>
|
||||||
null
|
v &&
|
||||||
|
setDraftSettings((prev) => ({ ...prev, calendar: v }))
|
||||||
}
|
}
|
||||||
onChange={handleDraftLanguageChange}
|
renderInput={(params) => (
|
||||||
renderInput={(p) => (
|
<TextField {...params} label={t('settings.calendar')} />
|
||||||
<TextField {...p} label={t('settings.language')} />
|
|
||||||
)}
|
)}
|
||||||
size="medium"
|
size="medium"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -189,51 +382,24 @@ export function Setting() {
|
|||||||
) : (
|
) : (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{t('settings.language')}
|
{t('settings.calendar')}
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1">
|
|
||||||
{
|
|
||||||
languageOptions.find((o) => o.code === savedLanguage)
|
|
||||||
?.label
|
|
||||||
}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Icon
|
||||||
|
Component={Calendar1}
|
||||||
|
size="medium"
|
||||||
|
color={mode === 'light' ? 'black' : 'primary.main'}
|
||||||
|
variant="Bold"
|
||||||
|
/>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{t(`settings.${savedSettings.calendar}`)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ mt: 2, width: { xs: '100%', md: '50%' } }}>
|
)}
|
||||||
{isEditing ? (
|
|
||||||
<Autocomplete
|
|
||||||
options={calendarOptions}
|
|
||||||
getOptionLabel={(key) => t(`settings.${key}`)}
|
|
||||||
value={selectedCalendar}
|
|
||||||
onChange={(_, v) => v && setSelectedCalendar(v)}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label={t('settings.calendar')} />
|
|
||||||
)}
|
|
||||||
size="medium"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{t('settings.calendar')}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Icon
|
|
||||||
Component={Calendar1}
|
|
||||||
size="medium"
|
|
||||||
color={mode === 'light' ? 'black' : 'primary.main'}
|
|
||||||
variant="Bold"
|
|
||||||
/>
|
|
||||||
<Typography variant="body1">
|
|
||||||
{t(`settings.${selectedCalendar}`)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Box, Button } from '@mui/material';
|
import { Box, Button, Typography, CircularProgress } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CardContainer } from '@/components/CardContainer';
|
import { CardContainer } from '@/components/CardContainer';
|
||||||
import { ProfileImage } from './personalInformation/ProfileImage';
|
import { ProfileImage } from './personalInformation/ProfileImage';
|
||||||
@@ -7,43 +7,193 @@ import { InfoRowDisplay } from './personalInformation/InfoRowDisplay';
|
|||||||
import { InfoRowEdit } from './personalInformation/InfoRowEdit';
|
import { InfoRowEdit } from './personalInformation/InfoRowEdit';
|
||||||
import { PageWrapper } from '../PageWrapper';
|
import { PageWrapper } from '../PageWrapper';
|
||||||
import { Gender, type InfoRowData } from '../../types';
|
import { Gender, type InfoRowData } from '../../types';
|
||||||
|
import axios, { isAxiosError } from 'axios';
|
||||||
|
import apiClient from '@/lib/apiClient';
|
||||||
|
|
||||||
|
interface GetProfileApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
nationalCode?: string;
|
||||||
|
gender?: Gender;
|
||||||
|
countryCode?: string;
|
||||||
|
profileImageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveProfileApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenApiResponse {
|
||||||
|
access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function PersonalInformation() {
|
export function PersonalInformation() {
|
||||||
const { t } = useTranslation('profileSetting');
|
const { t } = useTranslation('setting');
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
|
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<InfoRowData>({
|
||||||
const initialData: InfoRowData = {
|
firstName: '',
|
||||||
firstName: 'محمد حسین',
|
lastName: '',
|
||||||
lastName: 'برزهگر',
|
|
||||||
country: 'قطر',
|
|
||||||
nationalCode: '',
|
nationalCode: '',
|
||||||
gender: Gender.None,
|
gender: Gender.None,
|
||||||
};
|
country: '',
|
||||||
|
});
|
||||||
|
const [originalData, setOriginalData] = useState<InfoRowData | null>(null);
|
||||||
|
// const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const storedToken = localStorage.getItem('authToken');
|
||||||
|
|
||||||
const [data, setData] = useState<InfoRowData>(initialData);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [gender, setGender] = useState<Gender>(Gender.None);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.values(Gender).includes(data.gender)) {
|
const fetchProfile = async () => {
|
||||||
setGender(data.gender);
|
setIsLoading(true);
|
||||||
}
|
setFetchError(null);
|
||||||
}, [data.gender]);
|
try {
|
||||||
|
const res = await apiClient.post<GetProfileApiResponse>(
|
||||||
|
'/Profile/GetProfile',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${storedToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const profile = res.data;
|
||||||
|
const fetchedData = {
|
||||||
|
firstName: profile.firstName ?? '',
|
||||||
|
lastName: profile.lastName ?? '',
|
||||||
|
nationalCode: profile.nationalCode ?? '',
|
||||||
|
gender: Object.values(Gender).includes(profile.gender as Gender)
|
||||||
|
? (profile.gender as Gender)
|
||||||
|
: Gender.None,
|
||||||
|
country: profile.countryCode ?? '',
|
||||||
|
};
|
||||||
|
setData(fetchedData);
|
||||||
|
setOriginalData(fetchedData);
|
||||||
|
setUploadedImageUrl(profile.profileImageUrl || null);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.data.message || t('settingForm.failRetrieve'));
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
let message = t('settingForm.errorFetch');
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
setFetchError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const initials = `${data.firstName?.trim()[0] || ''}${data.lastName?.trim()[0] || ''}`;
|
if (storedToken) {
|
||||||
|
fetchProfile();
|
||||||
const toggleEdit = () => {
|
|
||||||
if (isEditing) {
|
|
||||||
setData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
gender: gender,
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
setGender(
|
setIsLoading(false);
|
||||||
Object.values(Gender).includes(data.gender) ? data.gender : Gender.None,
|
setFetchError(t('settingForm.notLoggedIn'));
|
||||||
);
|
}
|
||||||
|
}, [storedToken, t]);
|
||||||
|
|
||||||
|
const initials = `${data?.firstName?.trim()[0] || ''}${
|
||||||
|
data?.lastName?.trim()[0] || ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const handleEditClick = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setOriginalData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelClick = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (originalData) {
|
||||||
|
setData(originalData);
|
||||||
|
}
|
||||||
|
setSaveError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveClick = async () => {
|
||||||
|
if (!data) return;
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('FirstName', data.firstName || '');
|
||||||
|
formData.append('LastName', data.lastName || '');
|
||||||
|
formData.append('NationalCode', data.nationalCode || '');
|
||||||
|
formData.append('Gender', String(data.gender ?? Gender.None));
|
||||||
|
formData.append('CountryCode', data.country || '');
|
||||||
|
|
||||||
|
if (uploadedImageUrl && uploadedImageUrl.startsWith('data:')) {
|
||||||
|
const response = await fetch(uploadedImageUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
formData.append('Image', blob, 'profile.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post<SaveProfileApiResponse>(
|
||||||
|
'Profile/SaveProfilePersonalInforamtion',
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${storedToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
setIsEditing(false);
|
||||||
|
setOriginalData(data);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.data.message || t('settingForm.unknownError'));
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
let message = t('settingForm.checkConnection');
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
setSaveError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiUrl = 'https://accounts.business-harmony.com';
|
||||||
|
const tokenEndpoint = `${apiUrl}/connect/token`;
|
||||||
|
const getToken = async () => {
|
||||||
|
setTokenError(null);
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set('grant_type', 'password');
|
||||||
|
body.set('username', 'zareian.1381@gmail.com');
|
||||||
|
body.set('password', '123@Qweasd');
|
||||||
|
body.set('client_id', 'harmony_identity');
|
||||||
|
body.set('scope', 'openid harmony_identity profile offline_access');
|
||||||
|
const response = await axios.post<TokenApiResponse>(
|
||||||
|
tokenEndpoint,
|
||||||
|
body.toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.data?.access_token) {
|
||||||
|
localStorage.setItem('authToken', response.data.access_token);
|
||||||
|
} else {
|
||||||
|
throw new Error('No access token in response');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
let message = 'Failed to get token';
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
message = `Request failed with status ${error.response.status}`;
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
setTokenError(message);
|
||||||
}
|
}
|
||||||
setIsEditing(!isEditing);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,78 +203,140 @@ export function PersonalInformation() {
|
|||||||
subtitle={t('settingForm.descriptionPersonalInfo')}
|
subtitle={t('settingForm.descriptionPersonalInfo')}
|
||||||
highlighted={isEditing}
|
highlighted={isEditing}
|
||||||
action={
|
action={
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
<Box
|
||||||
{isEditing && (
|
sx={{
|
||||||
<Button
|
display: 'flex',
|
||||||
variant="text"
|
flexDirection: 'column',
|
||||||
onClick={() => setIsEditing(false)}
|
alignItems: 'flex-end',
|
||||||
size="large"
|
width: '100%',
|
||||||
sx={{
|
}}
|
||||||
color: 'primary.main',
|
>
|
||||||
textTransform: 'none',
|
<Box
|
||||||
width: { xs: '100%', sm: 'auto' },
|
|
||||||
// fontSize: { xs: '0.8 5rem', sm: '1rem' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('settingForm.rejectButton')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={toggleEdit}
|
|
||||||
size="large"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 1,
|
display: 'flex',
|
||||||
bgcolor: isEditing ? 'primary.main' : 'background.default',
|
gap: 1,
|
||||||
color: isEditing ? 'primary.contrastText' : 'primary.main',
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditing
|
<Button
|
||||||
? t('settingForm.saveButton')
|
variant="contained"
|
||||||
: t('settingForm.editButton')}
|
color="secondary"
|
||||||
</Button>
|
onClick={getToken}
|
||||||
|
size="large"
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Get Token
|
||||||
|
</Button>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={handleCancelClick}
|
||||||
|
size="large"
|
||||||
|
sx={{
|
||||||
|
color: 'primary.main',
|
||||||
|
textTransform: 'none',
|
||||||
|
width: { xs: '100%', sm: 'auto' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settingForm.rejectButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveClick}
|
||||||
|
size="large"
|
||||||
|
variant="contained"
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
width: { xs: '100%', sm: 'auto' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settingForm.saveButton')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleEditClick}
|
||||||
|
size="large"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t('settingForm.editButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{saveError && (
|
||||||
|
<Typography
|
||||||
|
color="error"
|
||||||
|
sx={{ mt: 1, textAlign: 'right', width: '100%' }}
|
||||||
|
>
|
||||||
|
{saveError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box
|
{isLoading ? (
|
||||||
sx={{
|
<Box
|
||||||
mx: { xs: 2, sm: 3, md: 4 },
|
sx={{
|
||||||
py: 2,
|
display: 'flex',
|
||||||
display: 'flex',
|
justifyContent: 'center',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
gap: 2,
|
p: 4,
|
||||||
bgcolor: 'background.paper',
|
minHeight: '200px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditing && (
|
<CircularProgress />
|
||||||
<ProfileImage
|
</Box>
|
||||||
initials={initials}
|
) : fetchError ? (
|
||||||
uploadedImageUrl={uploadedImageUrl}
|
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||||
onImageChange={(file) => {
|
<Typography color="error">{fetchError}</Typography>
|
||||||
const reader = new FileReader();
|
</Box>
|
||||||
reader.onload = () =>
|
) : (
|
||||||
setUploadedImageUrl(reader.result as string);
|
<>
|
||||||
reader.readAsDataURL(file);
|
{tokenError && (
|
||||||
|
<Box sx={{ mt: 2, color: 'red' }}>Error: {tokenError}</Box>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mx: { xs: 2, sm: 3, md: 4 },
|
||||||
|
py: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
}}
|
}}
|
||||||
onRemoveImage={() => setUploadedImageUrl(null)}
|
>
|
||||||
/>
|
{isEditing && (
|
||||||
)}
|
<ProfileImage
|
||||||
|
initials={initials}
|
||||||
{isEditing ? (
|
uploadedImageUrl={uploadedImageUrl}
|
||||||
<InfoRowEdit
|
onImageChange={(file) => {
|
||||||
data={data}
|
const reader = new FileReader();
|
||||||
setData={setData}
|
reader.onload = () =>
|
||||||
gender={gender}
|
setUploadedImageUrl(reader.result as string);
|
||||||
setGender={setGender}
|
reader.readAsDataURL(file);
|
||||||
/>
|
}}
|
||||||
) : (
|
onRemoveImage={() => setUploadedImageUrl(null)}
|
||||||
<InfoRowDisplay
|
/>
|
||||||
data={data}
|
)}
|
||||||
uploadedImageUrl={uploadedImageUrl}
|
{data &&
|
||||||
initials={initials}
|
(isEditing ? (
|
||||||
/>
|
<InfoRowEdit data={data} setData={setData} />
|
||||||
)}
|
) : (
|
||||||
</Box>
|
<InfoRowDisplay
|
||||||
|
data={data}
|
||||||
|
uploadedImageUrl={uploadedImageUrl}
|
||||||
|
initials={initials}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import parsePhoneNumberFromString from 'libphonenumber-js';
|
import parsePhoneNumberFromString from 'libphonenumber-js';
|
||||||
import { PageWrapper } from '../PageWrapper';
|
import { PageWrapper } from '../PageWrapper';
|
||||||
@@ -6,9 +6,32 @@ import { CardContainer } from '@/components/CardContainer';
|
|||||||
import PhoneDisplay from './phoneNumber/PhoneDisplay';
|
import PhoneDisplay from './phoneNumber/PhoneDisplay';
|
||||||
import PhoneEditForm from './phoneNumber/PhoneEditForm';
|
import PhoneEditForm from './phoneNumber/PhoneEditForm';
|
||||||
import PhoneActionButtons from './phoneNumber/PhoneActionButtons';
|
import PhoneActionButtons from './phoneNumber/PhoneActionButtons';
|
||||||
|
import apiClient from '@/lib/apiClient';
|
||||||
|
import { CircularProgress, Box, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
interface Phone {
|
||||||
|
phone: string;
|
||||||
|
time: string;
|
||||||
|
withCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetProfileApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmApiResponse extends ApiResponse {
|
||||||
|
confirm?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function PhoneNumber() {
|
export function PhoneNumber() {
|
||||||
const { t } = useTranslation('profileSetting');
|
const { t } = useTranslation('setting');
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [phoneNumber, setPhoneNumber] = useState('');
|
const [phoneNumber, setPhoneNumber] = useState('');
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
@@ -18,15 +41,60 @@ export function PhoneNumber() {
|
|||||||
);
|
);
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
const [isVerified, setIsVerified] = useState(false);
|
const [isVerified, setIsVerified] = useState(false);
|
||||||
const [phones, setPhone] = useState([
|
const [phones, setPhone] = useState<Phone[]>([]);
|
||||||
{ phone: '09123456789', time: '۱ ماه پیش', withCode: '+989123456789' },
|
|
||||||
]);
|
|
||||||
const [countryCode, setCountryCode] = useState('+98');
|
const [countryCode, setCountryCode] = useState('+98');
|
||||||
const textFieldRef = useRef<HTMLDivElement>(null);
|
const textFieldRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [touched, setTouched] = useState<boolean>(false);
|
const [touched, setTouched] = useState<boolean>(false);
|
||||||
const inputError: boolean = touched && !!error;
|
const inputError: boolean = touched && !!error;
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPhoneNumber = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFetchError(null);
|
||||||
|
if (!token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setFetchError(t('settingForm.notLoggedIn'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<GetProfileApiResponse>(
|
||||||
|
'/Profile/GetProfile',
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success && res.data.phoneNumber) {
|
||||||
|
setPhone([
|
||||||
|
{
|
||||||
|
phone: res.data.phoneNumber,
|
||||||
|
time: '',
|
||||||
|
withCode: res.data.phoneNumber,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else if (!res.data.success) {
|
||||||
|
throw new Error(
|
||||||
|
res.data.message || t('settingForm.failFetchPhoneNumber'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
let message = t('settingForm.errorFetchPhoneNumber');
|
||||||
|
if (err instanceof Error) {
|
||||||
|
message = err.message;
|
||||||
|
}
|
||||||
|
setFetchError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPhoneNumber();
|
||||||
|
}, [token, t]);
|
||||||
|
|
||||||
const isPhoneValid = (code: string, phone: string) => {
|
const isPhoneValid = (code: string, phone: string) => {
|
||||||
const phoneNum = parsePhoneNumberFromString(code + phone);
|
const phoneNum = parsePhoneNumberFromString(code + phone);
|
||||||
@@ -37,6 +105,7 @@ export function PhoneNumber() {
|
|||||||
setTouched(true);
|
setTouched(true);
|
||||||
if (!phoneNumber) {
|
if (!phoneNumber) {
|
||||||
setError(t('settingForm.thisFieldIsRequired'));
|
setError(t('settingForm.thisFieldIsRequired'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!isPhoneValid(countryCode, phoneNumber)) {
|
if (!isPhoneValid(countryCode, phoneNumber)) {
|
||||||
setError(t('settingForm.phoneNumberIsInvalid'));
|
setError(t('settingForm.phoneNumberIsInvalid'));
|
||||||
@@ -54,34 +123,100 @@ export function PhoneNumber() {
|
|||||||
setIsVerified(false);
|
setIsVerified(false);
|
||||||
setButtonState('default');
|
setButtonState('default');
|
||||||
setShowToast(false);
|
setShowToast(false);
|
||||||
|
setError(undefined);
|
||||||
|
setTouched(false);
|
||||||
}
|
}
|
||||||
return enteringEditMode;
|
return enteringEditMode;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendCode = () => {
|
const handleSendCode = async () => {
|
||||||
if (!phoneNumber) return;
|
if (!phoneNumber) return;
|
||||||
setButtonState('counting');
|
if (!isPhoneValid(countryCode, phoneNumber)) {
|
||||||
setIsVerified(false);
|
setError(t('settingForm.phoneNumberIsInvalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<ApiResponse>(
|
||||||
|
'/Profile/SendVerfiyPhoneNumberCode',
|
||||||
|
{
|
||||||
|
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (res.data.success) {
|
||||||
|
setButtonState('counting');
|
||||||
|
setIsVerified(false);
|
||||||
|
} else {
|
||||||
|
setError(res.data.message || t('settingForm.sendCodeFailed'));
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setError(t('settingForm.sendCodeFailed'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifyCode = () => {
|
const handleVerifyCode = async () => {
|
||||||
|
if (!verificationCode) {
|
||||||
|
setError(t('settingForm.verificationCodeRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsVerifying(true);
|
setIsVerifying(true);
|
||||||
setTimeout(() => {
|
setError(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<ConfirmApiResponse>(
|
||||||
|
'/Profile/ConfirmPhoneNumberChangeCode',
|
||||||
|
{
|
||||||
|
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
|
||||||
|
verifyCode: verificationCode,
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success && res.data.confirm) {
|
||||||
|
setIsVerified(true);
|
||||||
|
setShowToast(true);
|
||||||
|
await handleChangePhoneNumber();
|
||||||
|
} else {
|
||||||
|
setError(res.data.message || t('settingForm.verifyCodeFailed'));
|
||||||
|
setIsVerified(false);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setError(t('settingForm.verifyCodeFailed'));
|
||||||
|
setIsVerified(false);
|
||||||
|
} finally {
|
||||||
setIsVerifying(false);
|
setIsVerifying(false);
|
||||||
setIsVerified(true);
|
}
|
||||||
setShowToast(true);
|
|
||||||
const newPhone = '+98' + phoneNumber.slice(1);
|
|
||||||
setPhone([
|
|
||||||
{ phone: phoneNumber, time: 'چند ثانیه پیش', withCode: newPhone },
|
|
||||||
]);
|
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifyClick = () => {
|
const handleChangePhoneNumber = async () => {
|
||||||
if (!verificationCode || isVerifying) return;
|
try {
|
||||||
handleVerifyCode();
|
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
|
||||||
setTimeout(() => setShowToast(true), 1600);
|
const res = await apiClient.post<ApiResponse>(
|
||||||
|
'/Profile/ChangePhoneNumber',
|
||||||
|
{
|
||||||
|
phoneNumber: fullPhoneNumber,
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
setPhone([
|
||||||
|
{
|
||||||
|
phone: phoneNumber,
|
||||||
|
time: t('settingForm.justNow'),
|
||||||
|
withCode: fullPhoneNumber,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setIsEditing(false);
|
||||||
|
} else {
|
||||||
|
setError(res.data.message || t('settingForm.changePhoneFailed'));
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setError(t('settingForm.changePhoneFailed'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,7 +233,23 @@ export function PhoneNumber() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isLoading ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 4,
|
||||||
|
minHeight: '150px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : fetchError ? (
|
||||||
|
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||||
|
<Typography color="error">{fetchError}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : isEditing ? (
|
||||||
<PhoneEditForm
|
<PhoneEditForm
|
||||||
phoneNumber={phoneNumber}
|
phoneNumber={phoneNumber}
|
||||||
setPhoneNumber={setPhoneNumber}
|
setPhoneNumber={setPhoneNumber}
|
||||||
@@ -111,7 +262,7 @@ export function PhoneNumber() {
|
|||||||
buttonState={buttonState}
|
buttonState={buttonState}
|
||||||
setButtonState={setButtonState}
|
setButtonState={setButtonState}
|
||||||
handleSendCode={handleSendCode}
|
handleSendCode={handleSendCode}
|
||||||
handleVerifyClick={handleVerifyClick}
|
handleVerifyClick={handleVerifyCode}
|
||||||
error={error}
|
error={error}
|
||||||
inputError={inputError}
|
inputError={inputError}
|
||||||
handleBlur={handleBlur}
|
handleBlur={handleBlur}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CardContainer } from '@/components/CardContainer';
|
import { CardContainer } from '@/components/CardContainer';
|
||||||
import { PageWrapper } from '../PageWrapper';
|
import { PageWrapper } from '../PageWrapper';
|
||||||
@@ -7,13 +7,53 @@ import SocialMediaMenu from './socialMedia/SocialMediaMenu';
|
|||||||
import SocialMediaDialog from './socialMedia/SocialMediaDialog';
|
import SocialMediaDialog from './socialMedia/SocialMediaDialog';
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import type { Theme } from '@mui/material/styles';
|
import type { Theme } from '@mui/material/styles';
|
||||||
|
import apiClient from '@/lib/apiClient';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
interface EmailAccount {
|
||||||
|
email: string;
|
||||||
|
provider: 'email' | 'google';
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetProfileApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendCodeApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmCodeApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
confirm?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeEmailApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function SocialMedia() {
|
export function SocialMedia() {
|
||||||
const { t } = useTranslation('profileSetting');
|
const { t } = useTranslation('setting');
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false);
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
const [emailInput, setEmailInput] = useState('');
|
const [emailInput, setEmailInput] = useState('');
|
||||||
const [emailError, setEmailError] = useState(false);
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
|
const [dialogStep, setDialogStep] = useState<'enterEmail' | 'enterCode'>(
|
||||||
|
'enterEmail',
|
||||||
|
);
|
||||||
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [emailList, setEmailList] = useState<EmailAccount[]>([]);
|
||||||
|
|
||||||
|
const [isFetching, setIsFetching] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fullScreen = useMediaQuery((theme: Theme) =>
|
const fullScreen = useMediaQuery((theme: Theme) =>
|
||||||
theme.breakpoints.down('sm'),
|
theme.breakpoints.down('sm'),
|
||||||
@@ -26,10 +66,128 @@ export function SocialMedia() {
|
|||||||
| 'lg'
|
| 'lg'
|
||||||
| 'xl';
|
| 'xl';
|
||||||
|
|
||||||
const emailList = [
|
useEffect(() => {
|
||||||
{ email: 'emailtemp@email.com', provider: 'email', time: '1 ماه پیش' },
|
const fetchInitialEmail = async () => {
|
||||||
{ email: 'emailtemp@gmail.com', provider: 'google', time: '1 ماه پیش' },
|
setIsFetching(true);
|
||||||
] as const;
|
setFetchError(null);
|
||||||
|
if (!token) {
|
||||||
|
setIsFetching(false);
|
||||||
|
setFetchError(t('settingForm.notLoggedIn'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<GetProfileApiResponse>(
|
||||||
|
'/Profile/GetProfile',
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.success && res.data.email) {
|
||||||
|
const userEmail = res.data.email;
|
||||||
|
const newAccount: EmailAccount = {
|
||||||
|
email: userEmail,
|
||||||
|
provider: userEmail.includes('gmail.com') ? 'google' : 'email',
|
||||||
|
time: '',
|
||||||
|
};
|
||||||
|
setEmailList([newAccount]);
|
||||||
|
} else if (!res.data.success) {
|
||||||
|
throw new Error(res.data.message || t('settingForm.failFetchEmail'));
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
let message = t('settingForm.errorFetchEmail');
|
||||||
|
if (err instanceof Error) {
|
||||||
|
message = err.message;
|
||||||
|
}
|
||||||
|
setFetchError(message);
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInitialEmail();
|
||||||
|
}, [token, t]);
|
||||||
|
|
||||||
|
const resetDialog = () => {
|
||||||
|
setOpenDialog(false);
|
||||||
|
setEmailInput('');
|
||||||
|
setVerificationCode('');
|
||||||
|
setApiError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
setDialogStep('enterEmail');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendCode = async () => {
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(emailInput)) {
|
||||||
|
setApiError(t('settingForm.emailIsInvalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<SendCodeApiResponse>(
|
||||||
|
'Profile/SendEmailChangeCode',
|
||||||
|
{ email: emailInput },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (res.data.success) {
|
||||||
|
setDialogStep('enterCode');
|
||||||
|
} else {
|
||||||
|
setApiError(res.data.message || t('settingForm.sendCodeFailed'));
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setApiError(t('settingForm.sendCodeFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAndChangeEmail = async () => {
|
||||||
|
if (verificationCode.length < 4) {
|
||||||
|
setApiError(t('settingForm.verificationCodeRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const confirmRes = await apiClient.post<ConfirmCodeApiResponse>(
|
||||||
|
'Profile/ConfirmEmailChangeCode',
|
||||||
|
{ email: emailInput, verifyCode: verificationCode },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmRes.data.success && confirmRes.data.confirm) {
|
||||||
|
const changeRes = await apiClient.post<ChangeEmailApiResponse>(
|
||||||
|
'Profile/ChangeEmail',
|
||||||
|
{ email: emailInput },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changeRes.data.success) {
|
||||||
|
setEmailList((prevList) => [
|
||||||
|
...prevList,
|
||||||
|
{
|
||||||
|
email: emailInput,
|
||||||
|
provider: 'email',
|
||||||
|
time: t('settingForm.justNow'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
resetDialog();
|
||||||
|
} else {
|
||||||
|
setApiError(
|
||||||
|
changeRes.data.message || t('settingForm.changeEmailFailed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setApiError(
|
||||||
|
confirmRes.data.message || t('settingForm.verifyCodeFailed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setApiError(t('settingForm.anErrorOccurred'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper>
|
<PageWrapper>
|
||||||
@@ -40,15 +198,38 @@ export function SocialMedia() {
|
|||||||
<SocialMediaMenu t={t} onOpenDialog={() => setOpenDialog(true)} />
|
<SocialMediaMenu t={t} onOpenDialog={() => setOpenDialog(true)} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SocialMediaList t={t} emailList={emailList} />
|
{isFetching ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 4,
|
||||||
|
minHeight: '100px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : fetchError ? (
|
||||||
|
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||||
|
<Typography color="error.main">{fetchError}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<SocialMediaList t={t} emailList={emailList} />
|
||||||
|
)}
|
||||||
<SocialMediaDialog
|
<SocialMediaDialog
|
||||||
open={openDialog}
|
open={openDialog}
|
||||||
onClose={() => setOpenDialog(false)}
|
onClose={resetDialog}
|
||||||
t={t}
|
t={t}
|
||||||
emailInput={emailInput}
|
emailInput={emailInput}
|
||||||
setEmailInput={setEmailInput}
|
setEmailInput={setEmailInput}
|
||||||
emailError={emailError}
|
verificationCode={verificationCode}
|
||||||
setEmailError={setEmailError}
|
setVerificationCode={setVerificationCode}
|
||||||
|
apiError={apiError}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dialogStep={dialogStep}
|
||||||
|
onSendCode={handleSendCode}
|
||||||
|
onConfirmEmail={handleConfirmAndChangeEmail}
|
||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
computedMaxWidth={computedMaxWidth}
|
computedMaxWidth={computedMaxWidth}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface DisplayFieldProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DisplayField({ label, value }: DisplayFieldProps) {
|
export function DisplayField({ label, value }: DisplayFieldProps) {
|
||||||
const { t } = useTranslation('profileSetting');
|
const { t } = useTranslation('setting');
|
||||||
const displayValue = value?.trim() || t('settingForm.notDetermined');
|
const displayValue = value?.trim() || t('settingForm.notDetermined');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function InfoRowDisplay({
|
|||||||
uploadedImageUrl,
|
uploadedImageUrl,
|
||||||
initials,
|
initials,
|
||||||
}: InfoRowDisplayProps) {
|
}: InfoRowDisplayProps) {
|
||||||
const { t } = useTranslation('profileSetting');
|
const { t } = useTranslation('setting');
|
||||||
const displayValue = (value: string) =>
|
const displayValue = (value: string) =>
|
||||||
value?.trim() || t('settingForm.notDetermined');
|
value?.trim() || t('settingForm.notDetermined');
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
TextField,
|
TextField,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
@@ -15,17 +16,10 @@ import { type InfoRowData } from '@/features/profile/types';
|
|||||||
interface InfoRowEditProps {
|
interface InfoRowEditProps {
|
||||||
data: InfoRowData;
|
data: InfoRowData;
|
||||||
setData: React.Dispatch<React.SetStateAction<InfoRowData>>;
|
setData: React.Dispatch<React.SetStateAction<InfoRowData>>;
|
||||||
gender: Gender;
|
|
||||||
setGender: React.Dispatch<React.SetStateAction<Gender>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoRowEdit({
|
export function InfoRowEdit({ data, setData }: InfoRowEditProps) {
|
||||||
data,
|
const { t } = useTranslation(['countries', 'setting']);
|
||||||
setData,
|
|
||||||
gender,
|
|
||||||
setGender,
|
|
||||||
}: InfoRowEditProps) {
|
|
||||||
const { t } = useTranslation(['countries', 'profileSetting']);
|
|
||||||
|
|
||||||
const countryOptions = countries.map((c) => ({
|
const countryOptions = countries.map((c) => ({
|
||||||
code: c.code,
|
code: c.code,
|
||||||
@@ -34,26 +28,27 @@ export function InfoRowEdit({
|
|||||||
|
|
||||||
const currentCountry =
|
const currentCountry =
|
||||||
countryOptions.find((c) => c.code === data.country) || null;
|
countryOptions.find((c) => c.code === data.country) || null;
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
name: 'firstName' as const,
|
||||||
|
label: t('settingForm.name', { ns: 'profileSetting' }),
|
||||||
|
value: data.firstName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lastName' as const,
|
||||||
|
label: t('settingForm.familyName', { ns: 'profileSetting' }),
|
||||||
|
value: data.lastName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'nationalCode' as const,
|
||||||
|
label: t('settingForm.nationalCode', { ns: 'profileSetting' }),
|
||||||
|
value: data.nationalCode,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
{[
|
{fields.map(({ name, label, value }) => (
|
||||||
{
|
|
||||||
name: 'firstName' as keyof InfoRowData,
|
|
||||||
label: t('settingForm.name', { ns: 'profileSetting' }),
|
|
||||||
value: data.firstName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lastName' as keyof InfoRowData,
|
|
||||||
label: t('settingForm.familyName', { ns: 'profileSetting' }),
|
|
||||||
value: data.lastName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'nationalCode' as keyof InfoRowData,
|
|
||||||
label: t('settingForm.nationalCode', { ns: 'profileSetting' }),
|
|
||||||
value: data.nationalCode,
|
|
||||||
},
|
|
||||||
].map(({ name, label, value }) => (
|
|
||||||
<Box
|
<Box
|
||||||
key={name}
|
key={name}
|
||||||
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
|
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
|
||||||
@@ -75,22 +70,17 @@ export function InfoRowEdit({
|
|||||||
|
|
||||||
<Box sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}>
|
<Box sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>
|
||||||
|
{t('settingForm.genderPlaceholder', { ns: 'profileSetting' })}
|
||||||
|
</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={gender}
|
value={data.gender === Gender.None ? '' : data.gender}
|
||||||
onChange={(e) => setGender(e.target.value as Gender)}
|
label={t('settingForm.genderPlaceholder', { ns: 'profileSetting' })}
|
||||||
displayEmpty
|
onChange={(e) =>
|
||||||
renderValue={(selected) =>
|
setData((prev) => ({
|
||||||
selected ? (
|
...prev,
|
||||||
selected === Gender.Male ? (
|
gender: e.target.value as Gender,
|
||||||
t('settingForm.man', { ns: 'profileSetting' })
|
}))
|
||||||
) : (
|
|
||||||
t('settingForm.woman', { ns: 'profileSetting' })
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{t('settingForm.genderPlaceholder', { ns: 'profileSetting' })}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MenuItem value={Gender.Male}>
|
<MenuItem value={Gender.Male}>
|
||||||
@@ -117,6 +107,7 @@ export function InfoRowEdit({
|
|||||||
renderOption={(props, option) => (
|
renderOption={(props, option) => (
|
||||||
<Box component="li" {...props} key={option.code}>
|
<Box component="li" {...props} key={option.code}>
|
||||||
<CountryFlag code={option.code} />
|
<CountryFlag code={option.code} />
|
||||||
|
{option.label}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function ProfileImage({
|
|||||||
onImageChange,
|
onImageChange,
|
||||||
onRemoveImage,
|
onRemoveImage,
|
||||||
}: ProfileImageProps) {
|
}: ProfileImageProps) {
|
||||||
const { t } = useTranslation('profileSetting');
|
const { t } = useTranslation('setting');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -80,7 +80,9 @@ export function ProfileImage({
|
|||||||
Component={Camera}
|
Component={Camera}
|
||||||
size="small"
|
size="small"
|
||||||
color={
|
color={
|
||||||
uploadedImageUrl && onRemoveImage ? 'primary.main' : 'white'
|
uploadedImageUrl && onRemoveImage
|
||||||
|
? 'primary.main'
|
||||||
|
: 'background.paper'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { type ReactElement, type ElementType } from 'react';
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -27,10 +28,15 @@ interface SocialMediaDialogProps {
|
|||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
emailInput: string;
|
emailInput: string;
|
||||||
setEmailInput: (val: string) => void;
|
setEmailInput: (val: string) => void;
|
||||||
emailError: boolean;
|
|
||||||
setEmailError: (val: boolean) => void;
|
|
||||||
fullScreen: boolean;
|
fullScreen: boolean;
|
||||||
computedMaxWidth: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
computedMaxWidth: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
verificationCode: string;
|
||||||
|
setVerificationCode: (value: string) => void;
|
||||||
|
apiError: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
dialogStep: 'enterEmail' | 'enterCode';
|
||||||
|
onSendCode: () => void;
|
||||||
|
onConfirmEmail: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SocialMediaDialog({
|
export default function SocialMediaDialog({
|
||||||
@@ -39,17 +45,16 @@ export default function SocialMediaDialog({
|
|||||||
t,
|
t,
|
||||||
emailInput,
|
emailInput,
|
||||||
setEmailInput,
|
setEmailInput,
|
||||||
emailError,
|
|
||||||
setEmailError,
|
|
||||||
fullScreen,
|
fullScreen,
|
||||||
computedMaxWidth,
|
computedMaxWidth,
|
||||||
|
verificationCode,
|
||||||
|
setVerificationCode,
|
||||||
|
apiError,
|
||||||
|
isLoading,
|
||||||
|
dialogStep,
|
||||||
|
onSendCode,
|
||||||
|
onConfirmEmail,
|
||||||
}: SocialMediaDialogProps) {
|
}: SocialMediaDialogProps) {
|
||||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setEmailInput(value);
|
|
||||||
setEmailError(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -64,7 +69,7 @@ export default function SocialMediaDialog({
|
|||||||
sx: {
|
sx: {
|
||||||
borderRadius: { xs: 0, sm: 2 },
|
borderRadius: { xs: 0, sm: 2 },
|
||||||
width: { xs: '100%', sm: '30%' },
|
width: { xs: '100%', sm: '30%' },
|
||||||
height: { xs: '100%', sm: '43%' },
|
height: 'auto',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -79,7 +84,7 @@ export default function SocialMediaDialog({
|
|||||||
bgcolor: 'background.default',
|
bgcolor: 'background.default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton onClick={onClose} aria-label="Close">
|
<IconButton onClick={onClose} aria-label="Close" disabled={isLoading}>
|
||||||
<Icon Component={CloseCircle} size="medium" color="primary.main" />
|
<Icon Component={CloseCircle} size="medium" color="primary.main" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{t('settingForm.addEmailButton')}
|
{t('settingForm.addEmailButton')}
|
||||||
@@ -105,24 +110,53 @@ export default function SocialMediaDialog({
|
|||||||
fullWidth
|
fullWidth
|
||||||
type="email"
|
type="email"
|
||||||
value={emailInput}
|
value={emailInput}
|
||||||
onChange={handleEmailChange}
|
onChange={(e) => setEmailInput(e.target.value)}
|
||||||
error={emailError}
|
|
||||||
helperText={emailError ? t('settingForm.emailError') : ''}
|
|
||||||
label={t('settingForm.email')}
|
label={t('settingForm.email')}
|
||||||
placeholder="abc@email.com"
|
placeholder="abc@email.com"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
disabled={isLoading || dialogStep === 'enterCode'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{dialogStep === 'enterCode' && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="text"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
|
label={t('settingForm.verificationCode')}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||||
|
autoFocus
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiError && (
|
||||||
|
<Typography color="error" variant="caption" sx={{ mt: 1 }}>
|
||||||
|
{apiError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ textTransform: 'none', borderRadius: 2 }}
|
sx={{ textTransform: 'none', borderRadius: 2, mt: 1 }}
|
||||||
disabled={emailError || emailInput === ''}
|
disabled={isLoading || (dialogStep === 'enterEmail' && !emailInput)}
|
||||||
|
onClick={dialogStep === 'enterEmail' ? onSendCode : onConfirmEmail}
|
||||||
>
|
>
|
||||||
{t('settingForm.verificationCodeButton')}
|
{isLoading ? (
|
||||||
|
<CircularProgress size={24} color="inherit" />
|
||||||
|
) : dialogStep === 'enterEmail' ? (
|
||||||
|
t('settingForm.verificationCodeButton')
|
||||||
|
) : (
|
||||||
|
t('settingForm.confirmAndSave')
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface SocialMediaListProps {
|
|||||||
|
|
||||||
export default function SocialMediaList({ emailList }: SocialMediaListProps) {
|
export default function SocialMediaList({ emailList }: SocialMediaListProps) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: '100%', borderRadius: '8px', p: { xs: 1, sm: 2 } }}>
|
<Box sx={{ width: '100%', borderRadius: 1, p: { xs: 1, sm: 2 } }}>
|
||||||
{emailList.map((item, index) => (
|
{emailList.map((item, index) => (
|
||||||
<Box
|
<Box
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export enum Gender {
|
export enum Gender {
|
||||||
Male = 'male',
|
Male = 1,
|
||||||
Female = 'female',
|
Female = 2,
|
||||||
None = '',
|
None = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InfoRowData {
|
export interface InfoRowData {
|
||||||
|
|||||||
26
src/hooks/useApi.ts
Normal file
26
src/hooks/useApi.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
type ApiFunction<T> = () => Promise<{ data: T }>;
|
||||||
|
|
||||||
|
export function useApi<T>(apiFunction: ApiFunction<T>) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFunction();
|
||||||
|
setData(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [apiFunction]);
|
||||||
|
|
||||||
|
return { data, loading, error };
|
||||||
|
}
|
||||||
55
src/lib/apiClient.ts
Normal file
55
src/lib/apiClient.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Function to get the token from local storage or state management
|
||||||
|
const getToken = () => localStorage.getItem('authToken');
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
// Define the base URL for all API requests
|
||||||
|
baseURL: 'https://accounts.business-harmony.com/api/',
|
||||||
|
|
||||||
|
// Set a timeout for requests (e.g., 10 seconds)
|
||||||
|
timeout: 10000,
|
||||||
|
|
||||||
|
// Set default headers
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Request Interceptor ---
|
||||||
|
// This runs BEFORE each request is sent
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
// Add the authorization token to the headers
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// Handle request errors
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Response Interceptor ---
|
||||||
|
// This runs AFTER a response is received
|
||||||
|
// TODO: set global post api logic
|
||||||
|
// apiClient.interceptors.response.use(
|
||||||
|
// (response) => {
|
||||||
|
// // Any status code within the 2xx range will trigger this function
|
||||||
|
// return response;
|
||||||
|
// },
|
||||||
|
// (error) => {
|
||||||
|
// // Handle common errors globally
|
||||||
|
// if (error.response?.status === 401) {
|
||||||
|
// // e.g., redirect to login page if unauthorized
|
||||||
|
// console.error("Unauthorized! Redirecting to login...");
|
||||||
|
// // window.location.href = '/login';
|
||||||
|
// }
|
||||||
|
// return Promise.reject(error);
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
12
src/utils/persianDigit.tsx
Normal file
12
src/utils/persianDigit.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const toLocaleDigits = (
|
||||||
|
input: string | number,
|
||||||
|
lang: string,
|
||||||
|
): string => {
|
||||||
|
const str = String(input);
|
||||||
|
|
||||||
|
if (lang.startsWith('fa')) {
|
||||||
|
return str.replace(/\d/g, (d: string) => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d, 10)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
};
|
||||||
15
src/utils/regex.ts
Normal file
15
src/utils/regex.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function regex(password: string) {
|
||||||
|
const hasNumber = /\d/.test(password);
|
||||||
|
const hasMinLength = password.length >= 8;
|
||||||
|
const hasUpperAndLower = /[A-Z]/.test(password) && /[a-z]/.test(password);
|
||||||
|
const hasSpecialChar = /[!@#$%^&*]/.test(password);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasNumber,
|
||||||
|
hasMinLength,
|
||||||
|
hasUpperAndLower,
|
||||||
|
hasSpecialChar,
|
||||||
|
validPassword:
|
||||||
|
hasNumber && hasMinLength && hasUpperAndLower && hasSpecialChar,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user