Merge branch 'develop' into feat/auth

This commit is contained in:
SajadMRjl
2025-08-16 02:45:21 +03:30
committed by GitHub
57 changed files with 7196 additions and 394 deletions

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
@rkheftan:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=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

1206
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,18 @@
"preview": "vite preview"
},
"dependencies": {
"@date-io/dayjs": "^3.2.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.2.0",
"@mui/material": "^7.3.1",
"@mui/stylis-plugin-rtl": "^7.2.0",
"@rkheftan/harmony-ui": "^0.1.6",
"@types/stylis": "^4.2.7",
"@mui/x-data-grid-premium": "^8.10.0",
"@mui/x-date-pickers": "^8.10.0",
"@rkheftan/harmony-ui": "^0.1.8",
"axios": "^1.11.0",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.0.0-0",
"dayjs": "^1.11.13",
"i18next": "^25.3.0",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -34,8 +39,8 @@
"devDependencies": {
"@eslint/js": "^9.29.0",
"@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/stylis": "^4.2.7",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",

View File

@@ -0,0 +1,38 @@
{
"completion": {
"title": "Completion of user account information",
"description": "Enter your business information",
"name": "Name",
"familyName": "Family Name",
"gender": "Gender",
"optionalNationalCode": "National Code(Optional)",
"determinePassword": "Determine Password",
"password": "Password",
"passwordRepetition": "Repeat password",
"determineEmail": "Connect your email",
"email": "Email",
"vericationCodeButton": "Send verification code",
"verificationCode": "verification code",
"checkCodeButton": "Check code",
"registerButton": "Confirm and Register",
"man": "male",
"woman": "female",
"hasNumber": "includes number",
"hasMinLength": "at least 8 characters",
"hasUpperAndLower": "includes a lowercase and uppercase letter",
"hasSpecialChar": "includes sign (!@#$%^&*)",
"notCompatibility": "does not match",
"emailCorrectForm": "Enter the correct email form.",
"agreementPart1": "By continuing the registration process, you agree to the",
"agreementLinkText": " Harmony Terms and Conditions",
"agreementPart2": ".",
"sent": "sent",
"country": "country",
"dateOfBirth": "Date of birth(optional)",
"submitSuccess": "Information successfully registered",
"submitError": "Error in registering information",
"submitting": "Submitting...",
"success": "Success",
"agreement": "1. Confidentiality of Information: Harmony commits under no circumstances to disclose users identity information, such as phone numbers, email addresses, passwords, user IDs, or any related data, to third parties.\n\n2. User information is used solely for providing authentication services and remains confidential even after account deactivation or termination.\n\n3. Harmony is obliged to implement necessary security measures to prevent unauthorized access.\n\n4. Responsibility for Account Security: Users must protect their accounts and choose strong, non-guessable passwords. Periodic password changes and immediate action in case of suspected unauthorized access are required. Any misuse of the account due to user negligence is the responsibility of the user.\n\n5. Security Breaches and Cyber Attacks: Harmony is not responsible for security breaches caused by cyber attacks beyond the system's control. However, Harmony employs up-to-date security standards and encryption to prevent such incidents.\n\n6. User Negligence in Protecting Information: If account information is disclosed due to user negligence or error, Harmony bears no responsibility. Determining such cases, based on system security logs, is the responsibility of Harmony's technical manager.\n\n7. Accurate Logging of Activities: All events related to registering, editing, and deleting information in the system are accurately and immutably logged. Claims regarding deletion or modification of data without logs are invalid unless supported by documentation provided by the user.\n\n8. Service Updates: Harmony services may be updated or changed over time. Continued use of the system after changes implies acceptance of the new terms. If users disagree, they may request account deletion.\n\n9. User Support: Support is provided only via email and phone, free of charge. Harmony is not obligated to provide in-person support or training beyond basic services.\n\n10. Official Communication Channels: Harmony communicates with users only via the phone number and email registered in the user account. Official announcements are sent through these channels.\n\n11. Official Domains for Communication: All emails from Harmony are sent exclusively from the domain harmony.id. Users must verify this to prevent phishing or similar attacks.\n\n12. Compliance with Iranian Laws: Users must comply with all applicable laws of the Islamic Republic of Iran, including the “Electronic Commerce Law,” “Computer Crimes Law,” and related legislation. Responsibility for violations rests with the user.\n\n13. Temporary Data Retention After Account Termination: Upon account termination or deletion, user information is stored securely for 30 days and permanently deleted thereafter.\n\n14. Ownership of User Data: All data submitted by users belongs to them. Harmony has no ownership over this information. Users are responsible for the accuracy, quality, and legality of their data.\n\n15. Purposeful Use of Identity Information: Collected identity information during registration is used only for authentication and basic services. It will not be shared with any third party without explicit user consent, except under a court order or legal authority.\n\n16. Permanent Data Confidentiality: Harmony commits to maintaining confidentiality of collected information even after the end of the user relationship or account closure.\n\n17. Limitation of Liability: Harmony is not liable for direct or indirect damages resulting from use or inability to use the authentication services.\n\n18. Disruptions in Communication Infrastructure: Harmony is not responsible for disruptions caused by the internet, infrastructure services, or other issues beyond its control.\n\n19. Force Majeure and Unforeseen Events: Harmony bears no responsibility for natural disasters, strikes, power outages, cyber attacks, or other events beyond its control that prevent service delivery.\n\n20. Services Dependent on Third Parties: If parts of the authentication services are provided by third parties, the usage terms of those services are the responsibility of those companies, and Harmony bears no liability.\n\n21. Guarantee of Data Access in Case of Service Termination: If Harmony ceases operations permanently, it commits to keeping servers active for two years and allowing users access to their data.\n\n22. Notification of Service Interruptions: If service interruption is necessary, Harmony must notify users at least 12 hours in advance via email or SMS."
}
}

View File

@@ -0,0 +1,247 @@
{
"country.afghanistan": "Afghanistan",
"country.aland_islands": "Aland islands",
"country.albania": "Albania",
"country.algeria": "Algeria",
"country.american_samoa": "American samoa",
"country.andorra": "Andorra",
"country.angola": "Angola",
"country.anguilla": "Anguilla",
"country.antarctica": "Antarctica",
"country.antigua_and_barbuda": "Antigua and barbuda",
"country.argentina": "Argentina",
"country.armenia": "Armenia",
"country.aruba": "Aruba",
"country.australia": "Australia",
"country.austria": "Austria",
"country.azerbaijan": "Azerbaijan",
"country.bahamas": "Bahamas",
"country.bahrain": "Bahrain",
"country.bangladesh": "Bangladesh",
"country.barbados": "Barbados",
"country.belarus": "Belarus",
"country.belgium": "Belgium",
"country.belize": "Belize",
"country.benin": "Benin",
"country.bermuda": "Bermuda",
"country.bhutan": "Bhutan",
"country.bolivia": "Bolivia",
"country.bosnia_and_herzegovina": "Bosnia and herzegovina",
"country.botswana": "Botswana",
"country.brazil": "Brazil",
"country.british_indian_ocean_territory": "British indian ocean territory",
"country.british_virgin_islands": "British virgin islands",
"country.brunei": "Brunei",
"country.bulgaria": "Bulgaria",
"country.burkina_faso": "Burkina faso",
"country.burundi": "Burundi",
"country.cambodia": "Cambodia",
"country.cameroon": "Cameroon",
"country.canada": "Canada",
"country.cape_verde": "Cape verde",
"country.cayman_islands": "Cayman islands",
"country.central_african_republic": "Central african republic",
"country.chad": "Chad",
"country.chile": "Chile",
"country.china": "China",
"country.christmas_island": "Christmas island",
"country.cocos_keeling_islands": "Cocos keeling islands",
"country.colombia": "Colombia",
"country.comoros": "Comoros",
"country.congo_brazzaville": "Congo brazzaville",
"country.congo_kinshasa": "Congo kinshasa",
"country.cook_islands": "Cook islands",
"country.costa_rica": "Costa rica",
"country.cote_divoire": "Cote divoire",
"country.croatia": "Croatia",
"country.cuba": "Cuba",
"country.curacao": "Curacao",
"country.cyprus": "Cyprus",
"country.czech_republic": "Czech republic",
"country.denmark": "Denmark",
"country.djibouti": "Djibouti",
"country.dominica": "Dominica",
"country.dominican_republic": "Dominican republic",
"country.ecuador": "Ecuador",
"country.egypt": "Egypt",
"country.el_salvador": "El salvador",
"country.equatorial_guinea": "Equatorial guinea",
"country.eritrea": "Eritrea",
"country.estonia": "Estonia",
"country.eswatini": "Eswatini",
"country.ethiopia": "Ethiopia",
"country.falkland_islands": "Falkland islands",
"country.faroe_islands": "Faroe islands",
"country.fiji": "Fiji",
"country.finland": "Finland",
"country.france": "France",
"country.french_guiana": "French guiana",
"country.french_polynesia": "French polynesia",
"country.gabon": "Gabon",
"country.gambia": "Gambia",
"country.georgia": "Georgia",
"country.germany": "Germany",
"country.ghana": "Ghana",
"country.gibraltar": "Gibraltar",
"country.greece": "Greece",
"country.greenland": "Greenland",
"country.grenada": "Grenada",
"country.guadeloupe": "Guadeloupe",
"country.guam": "Guam",
"country.guatemala": "Guatemala",
"country.guernsey": "Guernsey",
"country.guinea": "Guinea",
"country.guinea_bissau": "Guinea bissau",
"country.guyana": "Guyana",
"country.haiti": "Haiti",
"country.honduras": "Honduras",
"country.hong_kong": "Hong kong",
"country.hungary": "Hungary",
"country.iceland": "Iceland",
"country.india": "India",
"country.indonesia": "Indonesia",
"country.iran": "Iran",
"country.iraq": "Iraq",
"country.ireland": "Ireland",
"country.isle_of_man": "Isle of man",
"country.israel": "Israel",
"country.italy": "Italy",
"country.jamaica": "Jamaica",
"country.japan": "Japan",
"country.jersey": "Jersey",
"country.jordan": "Jordan",
"country.kazakhstan": "Kazakhstan",
"country.kenya": "Kenya",
"country.kiribati": "Kiribati",
"country.kosovo": "Kosovo",
"country.kuwait": "Kuwait",
"country.kyrgyzstan": "Kyrgyzstan",
"country.laos": "Laos",
"country.latvia": "Latvia",
"country.lebanon": "Lebanon",
"country.lesotho": "Lesotho",
"country.liberia": "Liberia",
"country.libya": "Libya",
"country.liechtenstein": "Liechtenstein",
"country.lithuania": "Lithuania",
"country.luxembourg": "Luxembourg",
"country.macau": "Macau",
"country.madagascar": "Madagascar",
"country.malawi": "Malawi",
"country.malaysia": "Malaysia",
"country.maldives": "Maldives",
"country.mali": "Mali",
"country.malta": "Malta",
"country.marshall_islands": "Marshall islands",
"country.martinique": "Martinique",
"country.mauritania": "Mauritania",
"country.mauritius": "Mauritius",
"country.mayotte": "Mayotte",
"country.mexico": "Mexico",
"country.micronesia": "Micronesia",
"country.moldova": "Moldova",
"country.monaco": "Monaco",
"country.mongolia": "Mongolia",
"country.montenegro": "Montenegro",
"country.montserrat": "Montserrat",
"country.morocco": "Morocco",
"country.mozambique": "Mozambique",
"country.myanmar": "Myanmar",
"country.namibia": "Namibia",
"country.nauru": "Nauru",
"country.nepal": "Nepal",
"country.netherlands": "Netherlands",
"country.new_caledonia": "New caledonia",
"country.new_zealand": "New zealand",
"country.nicaragua": "Nicaragua",
"country.niger": "Niger",
"country.nigeria": "Nigeria",
"country.niue": "Niue",
"country.norfolk_island": "Norfolk island",
"country.north_korea": "North korea",
"country.north_macedonia": "North macedonia",
"country.northern_mariana_islands": "Northern mariana islands",
"country.norway": "Norway",
"country.oman": "Oman",
"country.pakistan": "Pakistan",
"country.palau": "Palau",
"country.palestine": "Palestine",
"country.panama": "Panama",
"country.papua_new_guinea": "Papua new guinea",
"country.paraguay": "Paraguay",
"country.peru": "Peru",
"country.philippines": "Philippines",
"country.pitcairn_islands": "Pitcairn islands",
"country.poland": "Poland",
"country.portugal": "Portugal",
"country.puerto_rico": "Puerto rico",
"country.qatar": "Qatar",
"country.reunion": "Reunion",
"country.romania": "Romania",
"country.russia": "Russia",
"country.rwanda": "Rwanda",
"country.saint_barthelemy": "Saint barthelemy",
"country.saint_helena": "Saint helena",
"country.saint_kitts_and_nevis": "Saint kitts and nevis",
"country.saint_lucia": "Saint lucia",
"country.saint_martin": "Saint martin",
"country.saint_pierre_and_miquelon": "Saint pierre and miquelon",
"country.saint_vincent_and_the_grenadines": "Saint vincent and the grenadines",
"country.samoa": "Samoa",
"country.san_marino": "San marino",
"country.sao_tome_and_principe": "Sao tome and principe",
"country.saudi_arabia": "Saudi arabia",
"country.senegal": "Senegal",
"country.serbia": "Serbia",
"country.seychelles": "Seychelles",
"country.sierra_leone": "Sierra leone",
"country.singapore": "Singapore",
"country.sint_maarten": "Sint maarten",
"country.slovakia": "Slovakia",
"country.slovenia": "Slovenia",
"country.solomon_islands": "Solomon islands",
"country.somalia": "Somalia",
"country.south_africa": "South africa",
"country.south_georgia_and_south_sandwich_islands": "South georgia and south sandwich islands",
"country.south_korea": "South korea",
"country.south_sudan": "South sudan",
"country.spain": "Spain",
"country.sri_lanka": "Sri lanka",
"country.sudan": "Sudan",
"country.suriname": "Suriname",
"country.svalbard_and_jan_mayen": "Svalbard and jan mayen",
"country.sweden": "Sweden",
"country.switzerland": "Switzerland",
"country.syria": "Syria",
"country.taiwan": "Taiwan",
"country.tajikistan": "Tajikistan",
"country.tanzania": "Tanzania",
"country.thailand": "Thailand",
"country.timor_leste": "Timor leste",
"country.togo": "Togo",
"country.tokelau": "Tokelau",
"country.tonga": "Tonga",
"country.trinidad_and_tobago": "Trinidad and tobago",
"country.tunisia": "Tunisia",
"country.turkey": "Turkey",
"country.turkmenistan": "Turkmenistan",
"country.turks_and_caicos_islands": "Turks and caicos islands",
"country.tuvalu": "Tuvalu",
"country.us_virgin_islands": "Us virgin islands",
"country.uganda": "Uganda",
"country.ukraine": "Ukraine",
"country.united_arab_emirates": "United arab emirates",
"country.united_kingdom": "United kingdom",
"country.united_states": "United states",
"country.uruguay": "Uruguay",
"country.uzbekistan": "Uzbekistan",
"country.vanuatu": "Vanuatu",
"country.vatican_city": "Vatican city",
"country.venezuela": "Venezuela",
"country.vietnam": "Vietnam",
"country.wallis_and_futuna": "Wallis and futuna",
"country.western_sahara": "Western sahara",
"country.yemen": "Yemen",
"country.zambia": "Zambia",
"country.zimbabwe": "Zimbabwe"
}

View File

@@ -0,0 +1,128 @@
{
"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.",
"saving": "Saving..."
},
"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.",
"deleting": "Deleting..."
},
"settings": {
"title": "Base settings",
"description": "Change your base settings",
"editButton": "Edit",
"rejectButton": "Reject",
"saveButton": "Save",
"theme": "Theme and color",
"light": "Light",
"dark": "Dark",
"language": "زبان/language",
"calendar": "Calendar and date format",
"solar": "Solar",
"lunar": "Lunar",
"christian": "Christian",
"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?"
}
}

View File

@@ -0,0 +1,14 @@
{
"side": {
"account": "Account",
"personalInfo": "Personal information",
"phoneNumber": "Phone number",
"email": "Email",
"security": "Security",
"password": "Password",
"verifiedAddress": "Verified addresses",
"recentLogins": "Recent logins",
"activeDevices": "Active devices",
"settings": "Settings"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,182 @@
{
"country.afghanistan": "افغانستان",
"country.aland_islands": "جزایر آلند",
"country.albania": "آلبانی",
"country.algeria": "الجزایر",
"country.american_samoa": "ساموای آمریکایی",
"country.andorra": "آندورا",
"country.angola": "آنگولا",
"country.anguilla": "آنگویلا",
"country.antarctica": "جنوبگان",
"country.antigua_and_barbuda": "آنتیگوا و باربودا",
"country.argentina": "آرژانتین",
"country.armenia": "ارمنستان",
"country.aruba": "آروبا",
"country.australia": "استرالیا",
"country.austria": "اتریش",
"country.azerbaijan": "آذربایجان",
"country.bahamas": "باهاما",
"country.bahrain": "بحرین",
"country.bangladesh": "بنگلادش",
"country.barbados": "باربادوس",
"country.belarus": "بلاروس",
"country.belgium": "بلژیک",
"country.belize": "بلیز",
"country.benin": "بنین",
"country.bermuda": "برمودا",
"country.bhutan": "بوتان",
"country.bolivia": "بولیوی",
"country.bosnia_and_herzegovina": "بوسنی و هرزگوین",
"country.botswana": "بوتسوانا",
"country.brazil": "برزیل",
"country.british_virgin_islands": "جزایر ویرجین بریتانیا",
"country.brunei": "برونئی",
"country.bulgaria": "بلغارستان",
"country.burkina_faso": "بورکینافاسو",
"country.burundi": "بوروندی",
"country.cambodia": "کامبوج",
"country.cameroon": "کامرون",
"country.canada": "کانادا",
"country.cape_verde": "کیپ ورد",
"country.cayman_islands": "جزایر کیمن",
"country.central_african_republic": "جمهوری آفریقای مرکزی",
"country.chad": "چاد",
"country.chile": "شیلی",
"country.china": "چین",
"country.colombia": "کلمبیا",
"country.comoros": "کومور",
"country.costa_rica": "کاستاریکا",
"country.cote_divoire": "ساحل عاج",
"country.croatia": "کرواسی",
"country.cuba": "کوبا",
"country.cyprus": "قبرس",
"country.czech_republic": "جمهوری چک",
"country.denmark": "دانمارک",
"country.djibouti": "جیبوتی",
"country.dominica": "دومینیکا",
"country.dominican_republic": "جمهوری دومینیکن",
"country.ecuador": "اکوادور",
"country.egypt": "مصر",
"country.el_salvador": "السالوادور",
"country.equatorial_guinea": "گینه استوایی",
"country.eritrea": "اریتره",
"country.estonia": "استونی",
"country.eswatini": "سوازیلند",
"country.ethiopia": "اتیوپی",
"country.fiji": "فیجی",
"country.finland": "فنلاند",
"country.france": "فرانسه",
"country.gabon": "گابن",
"country.gambia": "گامبیا",
"country.georgia": "گرجستان",
"country.germany": "آلمان",
"country.ghana": "غنا",
"country.greece": "یونان",
"country.guatemala": "گواتمالا",
"country.guinea": "گینه",
"country.guinea_bissau": "گینه بیسائو",
"country.guyana": "گویان",
"country.haiti": "هائیتی",
"country.honduras": "هندوراس",
"country.hungary": "مجارستان",
"country.iceland": "ایسلند",
"country.india": "هندوستان",
"country.indonesia": "اندونزی",
"country.iran": "ایران",
"country.iraq": "عراق",
"country.ireland": "ایرلند",
"country.israel": "اسرائیل",
"country.italy": "ایتالیا",
"country.jamaica": "جامائیکا",
"country.japan": "ژاپن",
"country.jordan": "اردن",
"country.kazakhstan": "قزاقستان",
"country.kenya": "کنیا",
"country.kuwait": "کویت",
"country.kyrgyzstan": "قرقیزستان",
"country.laos": "لائوس",
"country.latvia": "لتونی",
"country.lebanon": "لبنان",
"country.lesotho": "لسوتو",
"country.liberia": "لیبریا",
"country.libya": "لیبی",
"country.luxembourg": "لوکزامبورگ",
"country.malaysia": "مالزی",
"country.maldives": "مالدیو",
"country.mali": "مالی",
"country.malta": "مالت",
"country.mauritania": "موریتانی",
"country.mauritius": "موریس",
"country.mexico": "مکزیک",
"country.moldova": "مولداوی",
"country.monaco": "موناکو",
"country.mongolia": "مغولستان",
"country.morocco": "مراکش",
"country.mozambique": "موزامبیک",
"country.myanmar": "میانمار",
"country.namibia": "نامیبیا",
"country.nepal": "نپال",
"country.netherlands": "هلند",
"country.new_zealand": "نیوزیلند",
"country.nicaragua": "نیکاراگوئه",
"country.niger": "نیجر",
"country.nigeria": "نیجریه",
"country.north_korea": "کره شمالی",
"country.north_macedonia": "مقدونیه",
"country.norway": "نروژ",
"country.oman": "عمان",
"country.pakistan": "پاکستان",
"country.palau": "پالائو",
"country.panama": "پاناما",
"country.papua_new_guinea": "پاپوآ گینه نو",
"country.paraguay": "پاراگوئه",
"country.peru": "پرو",
"country.philippines": "فیلیپین",
"country.poland": "لهستان",
"country.portugal": "پرتغال",
"country.qatar": "قطر",
"country.romania": "رومانی",
"country.russia": "روسیه",
"country.rwanda": "رواندا",
"country.saudi_arabia": "عربستان سعودی",
"country.senegal": "سنگال",
"country.serbia": "صربستان",
"country.seychelles": "سیشل",
"country.sierra_leone": "سیرالئون",
"country.singapore": "سنگاپور",
"country.south_africa": "آفریقای جنوبی",
"country.south_korea": "کره جنوبی",
"country.south_sudan": "سودان جنوبی",
"country.spain": "اسپانیا",
"country.sri_lanka": "سری‌لانکا",
"country.sudan": "سودان",
"country.suriname": "سورینام",
"country.sweden": "سوئد",
"country.switzerland": "سوئیس",
"country.syria": "سوریه",
"country.taiwan": "تایوان",
"country.tajikistan": "تاجیکستان",
"country.tanzania": "تانزانیا",
"country.thailand": "تایلند",
"country.timor_leste": "تیمور شرقی",
"country.togo": "توگو",
"country.tonga": "تونگا",
"country.trinidad_and_tobago": "ترینیداد و توباگو",
"country.tunisia": "تونس",
"country.turkey": "ترکیه",
"country.turkmenistan": "ترکمنستان",
"country.tuvalu": "تووالو",
"country.uganda": "اوگاندا",
"country.ukraine": "اوکراین",
"country.united_arab_emirates": "امارات متحده عربی",
"country.united_kingdom": "انگلستان",
"country.united_states": "ایالات متحده آمریکا",
"country.uruguay": "اروگوئه",
"country.uzbekistan": "ازبکستان",
"country.vanuatu": "وانواتو",
"country.venezuela": "ونزوئلا",
"country.vietnam": "ویتنام",
"country.yemen": "یمن",
"country.zambia": "زامبیا",
"country.zimbabwe": "زیمبابوه"
}

View File

@@ -0,0 +1,128 @@
{
"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": "خطایی رخ داد",
"saving": "در حال ذخیره‌سازی..."
},
"active": {
"activeDevices": "نشست های فعال",
"activeDevicesCaption": "مشاهده و مدیریت تمام نشست های فعال شما",
"deleteDevicesButton": "حذف بقیه نشست ها",
"deleteDevice": "حذف نشست",
"currentDevice": "دستگاه فعلی",
"minutesAgo": "{{count}} دقیقه پیش",
"justNow": "همین الان",
"notLoggedIn": "شما وارد سیستم نشده‌اید",
"failFetchActiveSessions": "دریافت نشست های فعال ناموفق بود.",
"errorFetch": "هنگام دریافت جلسات فعال شما خطایی روی داد.",
"deleting": "در حال حذف..."
},
"settings": {
"title": "تنظیمات پایه",
"description": "تنظیمات پایه‌ای حساب خود را تغییر دهید",
"editButton": "ویرایش",
"rejectButton": "لغو",
"saveButton": "ذخیره",
"theme": "تم و رنگ",
"light": "روشن",
"dark": "تاریک",
"language": "زبان/language",
"calendar": "فرمت تقویم و تاریخ",
"solar": "شمسی",
"lunar": "قمری",
"christian": "میلادی",
"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": "رمز عبور را فراموش کرده اید؟"
}
}

View File

@@ -0,0 +1,14 @@
{
"side": {
"account": "حساب کاربری",
"personalInfo": "اطلاعات شخصی",
"phoneNumber": "شماره تماس",
"email": "ایمیل",
"security": "امنیت",
"password": "رمز عبور",
"verifiedAddress": "آدرس های تایید شده",
"recentLogins": "ورود های اخیر",
"activeDevices": "دستگاه های فعال",
"settings": "تنظیمات"
}
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
interface CardContainerProps {
title: string;
subtitle: string;
action?: React.ReactNode;
children?: React.ReactNode;
highlighted?: boolean;
}
export function CardContainer({
title,
subtitle,
action,
children,
highlighted,
}: CardContainerProps) {
return (
<Box sx={{ width: '100%', bgcolor: 'background.paper' }}>
<Box
sx={{
marginInline: 'auto',
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: { xs: 'flex-start', sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' },
bgcolor: highlighted ? 'primary.light' : 'background.default',
p: 2,
borderRadius: 1,
gap: { xs: 1, sm: 0 },
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography
variant="h6"
color={highlighted ? 'primary.main' : 'text.primary'}
>
{title}
</Typography>
<Typography
color={highlighted ? 'primary.main' : 'text.secondary'}
variant="body2"
>
{subtitle}
</Typography>
</Box>
{action}
</Box>
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toLocaleDigits } from '@/utils/persianDigit';
interface CountdownTimerProps {
initialSeconds: number;
onComplete?: () => void;
}
export function CountDownTimer({
initialSeconds,
onComplete,
}: CountdownTimerProps) {
const { i18n } = useTranslation();
const [secondsLeft, setSecondsLeft] = useState(initialSeconds);
useEffect(() => {
setSecondsLeft(initialSeconds);
const timer = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
clearInterval(timer);
onComplete?.();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [initialSeconds, onComplete]);
const formatTime = (totalSeconds: number) => {
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
const seconds = String(totalSeconds % 60).padStart(2, '0');
return toLocaleDigits(`${minutes}:${seconds}`, i18n.language);
};
return <span>{formatTime(secondsLeft)}</span>;
}

View File

@@ -0,0 +1,40 @@
import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
// TODO: move countries outside of feature directory
import { countries } from '@/features/authentication/data/Countries';
interface CountryFlagProps {
code: string;
}
export function CountryFlag({ code }: CountryFlagProps) {
const { t } = useTranslation();
const countryObj = code ? countries.find((c) => c.code === code) : null;
if (!countryObj) {
return null;
}
const displayName = t(countryObj.label);
const flagUrl = `https://flagcdn.com/w40/${countryObj.code.toLowerCase()}.png`;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
component="img"
loading="lazy"
src={flagUrl}
alt={displayName}
sx={{
width: 24,
height: 16,
borderRadius: 0.5,
border: '1px solid',
borderColor: 'divider',
}}
/>
<Typography variant="body2">{displayName}</Typography>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
import { ToggleButtonGroup, ToggleButton, Box } from '@mui/material';
import { Sun1, Moon } from 'iconsax-react';
import { useTranslation } from 'react-i18next';
import { Icon } from '@rkheftan/harmony-ui';
import { type MouseEvent } from 'react';
enum ThemeMode {
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 handleChange = (
_event: MouseEvent<HTMLElement>,
newMode: 'light' | 'dark' | null,
) => {
if (newMode !== null) {
onChange(newMode);
}
};
return (
<Box>
<ToggleButtonGroup
value={value}
exclusive
onChange={handleChange}
sx={{
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
<ToggleButton
value={ThemeMode.Light}
aria-label="light theme"
sx={{
textTransform: 'none',
display: 'flex',
gap: 1,
px: 2,
py: 1,
'&.Mui-selected': {
bgcolor: 'primary.light',
color: 'primary.main',
},
}}
>
<Icon Component={Sun1} color="primary.main" variant="Bold" />
{t('settings.light')}
</ToggleButton>
<ToggleButton
value={ThemeMode.Dark}
aria-label="dark theme"
sx={{
textTransform: 'none',
display: 'flex',
gap: 1,
px: 2,
py: 1,
'&.Mui-selected': {
bgcolor: 'primary.light',
color: 'primary.main',
},
}}
>
<Icon Component={Moon} size="medium" color="primary.light" />
{t('settings.dark')}
</ToggleButton>
</ToggleButtonGroup>
</Box>
);
};

View File

@@ -0,0 +1,32 @@
import {
type SendEmailOtpPayload,
type ConfirmEmailOtpPayload,
type CompleteUserInfoPayload,
type CompleteUserInfoResponse,
type GenericApiResponse,
} from '../types/completionFormApiTypes';
import apiClient from '@/lib/apiClient';
export const sendEmailOtpApi = async (
payload: SendEmailOtpPayload,
): Promise<GenericApiResponse & { codeSentAnyway?: boolean }> => {
const { data } = await apiClient.post('/User/SendEmailOtp', payload);
return data;
};
export const confirmEmailOtpApi = async (
payload: ConfirmEmailOtpPayload,
): Promise<GenericApiResponse> => {
const { data } = await apiClient.post('/User/ConfirmEmailOtp', payload);
return data;
};
export const completeUserInformationApi = async (
payload: CompleteUserInfoPayload,
): Promise<CompleteUserInfoResponse> => {
const { data } = await apiClient.post(
'/User/CompleteUserInformation',
payload,
);
return data;
};

View File

@@ -0,0 +1,72 @@
import { useMemo } from 'react';
import {
DatePicker,
PickersDay,
type PickersDayProps,
} from '@mui/x-date-pickers';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { AdapterDateFnsJalali } from '@mui/x-date-pickers/AdapterDateFnsJalali';
import { enUS } from 'date-fns/locale';
import { faIR as faIRJalali } from 'date-fns-jalali/locale';
import { getDay } from 'date-fns-jalali';
import { format as formatJalali } from 'date-fns-jalali';
import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { toLocaleDigits } from '@/utils/persianDigit';
import { type DateOfBirthProps } from '../types/settingForm';
export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
const { t, i18n } = useTranslation('completionForm');
const isFarsi = i18n.language === 'fa' || i18n.language === 'fa-IR';
const { Adapter, locale, formatString, dayOfWeekFormatter } = useMemo(() => {
if (isFarsi) {
const persianDays = ['ی', 'د', 'س', 'چ', 'پ', 'ج', 'ش'];
return {
Adapter: AdapterDateFnsJalali,
locale: faIRJalali,
formatString: 'yyyy/MM/dd',
dayOfWeekFormatter: (date: Date) => persianDays[getDay(date)],
};
}
return {
Adapter: AdapterDateFns,
locale: enUS,
formatString: 'MM/dd/yyyy',
dayOfWeekFormatter: undefined,
};
}, [isFarsi]);
const CustomDay = (props: PickersDayProps) => {
const dayNumber = isFarsi
? formatJalali(props.day, 'dd')
: format(props.day, 'dd');
return (
<PickersDay {...props}>
{toLocaleDigits(dayNumber, i18n.language)}
</PickersDay>
);
};
return (
<LocalizationProvider dateAdapter={Adapter} adapterLocale={locale}>
<DatePicker
label={toLocaleDigits(t('completion.dateOfBirth'), i18n.language)}
value={value}
onChange={onChange}
format={formatString}
dayOfWeekFormatter={dayOfWeekFormatter}
slots={{ day: CustomDay }}
slotProps={{
textField: {
fullWidth: true,
sx: {
flex: '1 1 260px',
},
},
}}
/>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,184 @@
import React from 'react';
import {
TextField,
Box,
Button,
Switch,
FormGroup,
Typography,
InputAdornment,
IconButton,
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { TickCircle, Edit } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type EmailSectionProps } from '../types/settingForm';
export function EmailSection({
showEmail,
setShowEmail,
email,
setEmail,
correctEmail,
codeSent,
verificationCode,
setVerificationCode,
buttonState,
getButtonLabel,
handleSendCode,
handleVerifyCode,
emailVerified,
loading,
handleEditEmail,
}: EmailSectionProps) {
const { t } = useTranslation('completionForm');
const onSendCodeClick = () => {
if (!correctEmail) return;
handleSendCode();
};
const handleToggleEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowEmail(e.target.checked);
};
return (
<>
<FormGroup>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: { xs: 2, sm: 0 },
}}
>
<Switch checked={showEmail} onChange={handleToggleEmail} />
<Typography
sx={{ color: showEmail ? 'primary.main' : 'text.primary' }}
>
{t('completion.determineEmail')}
</Typography>
</Box>
</FormGroup>
{showEmail && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 0 },
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.email')}
variant="outlined"
fullWidth
value={email}
onChange={(e) => setEmail(e.target.value)}
error={email.length > 0 && !correctEmail}
sx={{ flex: '1 1 260px' }}
slotProps={{
input: {
startAdornment:
!loading && emailVerified ? (
<InputAdornment position="end">
<Icon
Component={TickCircle}
size="medium"
variant="Bold"
color="success.main"
/>
</InputAdornment>
) : null,
endAdornment:
buttonState === 'counting' ? (
<InputAdornment position="start">
<IconButton onClick={handleEditEmail}>
<Icon
Component={Edit}
color="primary.main"
size="medium"
/>
</IconButton>
</InputAdornment>
) : null,
sx: {
paddingLeft: buttonState === 'counting' ? 0 : undefined,
},
},
}}
/>
{!loading && !emailVerified && (
<Button
type="button"
variant="text"
onClick={onSendCodeClick}
sx={{
minWidth: '140px',
width: { xs: '100%', sm: '156px' },
alignSelf: 'center',
textTransform: 'none',
}}
>
{getButtonLabel()}
</Button>
)}
</Box>
{email && (
<Typography
sx={{ color: correctEmail ? 'success.main' : 'error.main' }}
variant="caption"
>
{correctEmail ? '' : t('completion.emailCorrectForm')}
</Typography>
)}
{!emailVerified && codeSent && correctEmail && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.verificationCode')}
variant="outlined"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
sx={{ flex: '1 1 260px' }}
disabled={loading}
/>
<Button
variant="contained"
onClick={handleVerifyCode}
disabled={loading}
sx={{
width: { xs: '100%', sm: '156px' },
border: 0.5,
textTransform: 'none',
}}
>
{loading ? (
<CircularProgress size={20} />
) : (
t('completion.checkCodeButton')
)}
</Button>
</Box>
)}
</Box>
)}
</>
);
}

View File

@@ -0,0 +1,250 @@
import React, { useState } from 'react';
import {
TextField,
Box,
IconButton,
Switch,
FormGroup,
Typography,
InputAdornment,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { TickCircle, Eye, EyeSlash, CloseCircle } from 'iconsax-react';
import { PasswordValidationItem } from './PasswordValidation';
import { Icon } from '@rkheftan/harmony-ui';
import { type PasswordSectionProps } from '../types/settingForm';
export function PasswordSection({
showPasswordSection,
setShowPasswordSection,
password,
setPassword,
confirmPassword,
setConfirmPassword,
matchPassword,
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword,
showValidations,
}: PasswordSectionProps) {
const { t } = useTranslation('completionForm');
const [showPasswordText, setShowPasswordText] = useState(false);
const [showPasswordRepetitionText, setShowPasswordRepetitionText] =
useState(false);
const handleTogglePasswordSection = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
setShowPasswordSection(e.target.checked);
};
const handleTogglePasswordEye = () => setShowPasswordText((prev) => !prev);
const handleTogglePasswordRepetitionEye = () =>
setShowPasswordRepetitionText((prev) => !prev);
return (
<>
<FormGroup>
<Box
sx={{
display: 'flex',
gap: 0.5,
px: { xs: 2, sm: -4 },
alignItems: 'center',
}}
>
<Switch
checked={showPasswordSection}
onChange={handleTogglePasswordSection}
/>
<Typography
sx={{
color: showPasswordSection ? 'primary.main' : 'text.primary',
}}
>
{t('completion.determinePassword')}
</Typography>
</Box>
</FormGroup>
{showPasswordSection && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 0 },
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
variant="outlined"
type={showPasswordText ? 'text' : 'password'}
sx={{
flex: '1 1 260px',
'& .MuiInputBase-input': {
pr: 8, // Increased padding to accommodate both icons
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minWidth: '64px', // Adjusted to fit both icons
}}
>
<IconButton
onClick={handleTogglePasswordEye}
sx={{ p: 0.5 }}
>
{showPasswordText ? (
<Icon
Component={Eye}
color="primary.main"
size="medium"
/>
) : (
<Icon
Component={EyeSlash}
size="medium"
color="primary.main"
/>
)}
</IconButton>
{validPassword && (
<Icon
Component={TickCircle}
size="medium"
color="success.main"
variant="Bold"
/>
)}
</Box>
</InputAdornment>
),
}}
/>
<TextField
label={t('completion.passwordRepetition')}
variant="outlined"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={confirmPassword.length > 0 && !matchPassword}
helperText={
confirmPassword.length > 0 && !matchPassword
? t('completion.notCompatibility')
: ' '
}
type={showPasswordRepetitionText ? 'text' : 'password'}
sx={{
flex: '1 1 260px',
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minWidth: '64px',
}}
>
<IconButton
onClick={handleTogglePasswordRepetitionEye}
sx={{ p: 0.5 }}
>
{showPasswordRepetitionText ? (
<Icon
Component={Eye}
color="primary.main"
size="medium"
/>
) : (
<Icon
Component={EyeSlash}
size="medium"
color="primary.main"
/>
)}
</IconButton>
{confirmPassword.length > 0 && (
<Icon
Component={matchPassword ? TickCircle : CloseCircle}
size="medium"
color={matchPassword ? 'success.main' : 'error.main'}
variant="Bold"
/>
)}
</Box>
</InputAdornment>
),
}}
/>
</Box>
{password && showValidations && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Box
sx={{
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
}}
>
<PasswordValidationItem
isValid={hasNumber}
label={t('completion.hasNumber')}
/>
<PasswordValidationItem
isValid={hasMinLength}
label={t('completion.hasMinLength')}
/>
</Box>
<Box
sx={{
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
}}
>
<PasswordValidationItem
isValid={hasUpperAndLower}
label={t('completion.hasUpperAndLower')}
/>
<PasswordValidationItem
isValid={hasSpecialChar}
label={t('completion.hasSpecialChar')}
/>
</Box>
</Box>
)}
</Box>
)}
</>
);
}

View File

@@ -0,0 +1,39 @@
import { Box, Typography } from '@mui/material';
import { TickCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type ValidationItemProps } from '../types/settingForm';
export function PasswordValidationItem({
isValid,
label,
}: ValidationItemProps) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 0.5,
flexWrap: 'wrap',
}}
>
<Icon
Component={TickCircle}
size="small"
color={isValid ? 'success.main' : 'primary.main'}
variant={isValid ? 'Bold' : 'Outline'}
/>
<Typography
variant="body2"
color="text.primary"
sx={{
wordBreak: 'break-word',
flex: 1,
}}
>
{label}
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,168 @@
import {
TextField,
FormControl,
InputLabel,
MenuItem,
Select,
Box,
Autocomplete,
type SelectChangeEvent,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { Woman, Man } from 'iconsax-react';
import DateOfBirth from './DateOfBirth';
import { countries } from '../data/Countries';
import { CountryFlag } from '@/components/CountryFlag';
import { Icon } from '@rkheftan/harmony-ui';
import { type PersonalInfoFieldsProps } from '../types/settingForm';
import { Gender } from '../types/settingForm';
export function PersonalInfoFields({
firstName,
setFirstName,
lastName,
setLastName,
nationalId,
setNationalId,
birthDate,
setBirthDate,
sex,
setSex,
country,
setCountry,
}: PersonalInfoFieldsProps) {
const { t } = useTranslation('completionForm');
const countryOptions = countries.map((c) => ({
code: c.code,
label: t(c.label, { ns: 'countries' }),
}));
const currentCountry = countryOptions.find((c) => c.code === country) || null;
const handleChangeSex = (e: SelectChangeEvent<Gender>) => {
setSex(e.target.value as Gender);
};
const genderOptions = [
{
value: Gender.Female,
icon: Woman,
label: t('completion.woman'),
color: '#F50057',
},
{
value: Gender.Male,
icon: Man,
label: t('completion.man'),
color: '#0091EA',
},
];
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%',
px: { xs: 2, sm: 4, md: 1 },
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.name')}
placeholder={t('completion.name')}
variant="outlined"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
sx={{ flex: '1 1 260px' }}
/>
<TextField
label={t('completion.familyName')}
placeholder={t('completion.familyName')}
variant="outlined"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
sx={{ flex: '1 1 260px' }}
/>
</Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<FormControl sx={{ flex: '1 1 260px' }}>
<InputLabel>{t('completion.gender')}</InputLabel>
<Select
value={sex}
label={t('completion.gender')}
onChange={handleChangeSex}
>
{genderOptions.map((g) => (
<MenuItem key={g.value} value={g.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon Component={g.icon} size="small" color={g.color} />
{g.label}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label={t('completion.optionalNationalCode')}
placeholder={t('completion.optionalNationalCode')}
value={nationalId}
onChange={(e) => setNationalId(e.target.value)}
variant="outlined"
sx={{ flex: '1 1 260px' }}
/>
</Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Autocomplete
sx={{ flex: '1 1 260px' }}
options={countryOptions}
getOptionLabel={(option) => option.label}
value={currentCountry}
onChange={(_, newValue) => setCountry(newValue?.code || '')}
renderOption={(props, option) => (
<Box component="li" {...props} key={option.code}>
<CountryFlag code={option.code} />
{option.label}
</Box>
)}
renderInput={(params) => (
<TextField {...params} label={t('completion.country')} />
)}
clearOnEscape
/>
<Box sx={{ flex: '1 1 260px' }}>
<DateOfBirth value={birthDate} onChange={setBirthDate} />
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import {
Box,
Button,
Typography,
Link,
Dialog,
DialogTitle,
DialogContent,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { type SubmitProps } from '../types/settingForm';
export function SubmitSection({ onSubmit, loading, error }: SubmitProps) {
const { t, i18n } = useTranslation('completionForm');
const [openDialog, setOpenDialog] = useState(false);
const handleOpenDialog = (e: React.MouseEvent) => {
e.preventDefault();
setOpenDialog(true);
};
const agreementText = t('completion.agreement');
return (
<>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
px: { xs: 2, sm: 0 },
mb: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Typography
variant="body2"
sx={{
flex: '1 1 260px',
maxWidth: 'calc(100% - 263px)',
minWidth: '260px',
}}
color="text.primary"
>
{t('completion.agreementPart1')}
<Link href="#" onClick={handleOpenDialog}>
{t('completion.agreementLinkText')}
</Link>
{t('completion.agreementPart2')}
</Typography>
<Button
variant="contained"
onClick={onSubmit}
disabled={loading}
sx={{
width: { xs: '100%', sm: '247px' },
alignSelf: { xs: 'stretch', sm: 'center' },
textTransform: 'none',
}}
>
{loading
? t('completion.submitting')
: t('completion.registerButton')}
</Button>
{error && <Typography color="error">{error}</Typography>}
</Box>
<Dialog
open={openDialog}
onClose={() => setOpenDialog(false)}
fullWidth
maxWidth="md"
dir={i18n.language.startsWith('fa') ? 'rtl' : 'ltr'}
>
<DialogTitle>
{t('completion.rules') || t('completion.rules')}
</DialogTitle>
<DialogContent
dividers
sx={{ whiteSpace: 'pre-line', fontSize: '14px' }}
>
{agreementText}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,329 @@
import { useEffect, useState } from 'react';
import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import Logo from '@/components/Logo';
import { PersonalInfoFields } from './PersonalInfoFields';
import { PasswordSection } from './PasswordSection';
import { EmailSection } from './EmailSection';
import { SubmitSection } from './SubmitSection';
import { useToast } from '@rkheftan/harmony-ui';
import { regex } from '../../../utils/regex';
import { toLocaleDigits } from '../../../utils/persianDigit';
import i18n from '@/config/i18n';
import { Gender } from '../types/settingForm';
import { useApi } from '@/hooks/useApi';
import {
sendEmailOtpApi,
confirmEmailOtpApi,
completeUserInformationApi,
} from '../api/userCompletion';
import {
type SendEmailOtpPayload,
type ConfirmEmailOtpPayload,
type CompleteUserInfoPayload,
} from '../types/completionFormApiTypes';
import { type ApiResponse } from '@/types/apiResponse';
export function UserCompletionForm() {
const { t } = useTranslation('completionForm');
const showToast = useToast();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [nationalId, setNationalId] = useState('');
const [birthDate, setBirthDate] = useState<Date | null>(null);
const [sex, setSex] = useState<Gender>(Gender.Female);
const [country, setCountry] = useState('');
const [showPasswordSection, setShowPasswordSection] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showEmail, setShowEmail] = useState(false);
const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [codeSent, setCodeSent] = useState(false);
const [buttonState, setButtonState] = useState<'default' | 'counting'>(
'default',
);
const [countdown, setCountdown] = useState(0);
const [emailVerified, setEmailVerified] = useState(false);
const [showPasswordValidations, setShowPasswordValidations] = useState(false);
const {
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword,
correctEmail,
} = regex(password, email);
const matchPassword = password === confirmPassword;
const { execute: sendCode, data: sendCodeData } = useApi(
async (payload: SendEmailOtpPayload) => {
const result = await sendEmailOtpApi(payload);
const conformingResult: ApiResponse = {
...result,
errorCode: result.errorCode || 0,
validations: [],
};
return { data: conformingResult };
},
);
const {
execute: verifyCode,
loading: isVerifyingCode,
data: verifyCodeData,
} = useApi(async (payload: ConfirmEmailOtpPayload) => {
const result = await confirmEmailOtpApi(payload);
const conformingResult: ApiResponse = {
...result,
errorCode: result.errorCode || 0,
validations: [],
};
return { data: conformingResult };
});
const {
execute: submitForm,
loading: isSubmitting,
error: submitError,
data: submitData,
} = useApi(async (payload: CompleteUserInfoPayload) => {
const result = await completeUserInformationApi(payload);
const conformingResult: ApiResponse = {
...result,
errorCode: result.errorCode || 0,
validations: [],
};
return { data: conformingResult };
});
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
useEffect(() => {
if (sendCodeData) {
if (sendCodeData.success) {
showToast({
message: sendCodeData.message || t('completion.successfullCodeSent'),
severity: 'success',
});
setCodeSent(true);
setButtonState('counting');
setCountdown(120);
} else {
showToast({
message: sendCodeData.message || t('completion.problem'),
severity: 'error',
});
}
}
}, [sendCodeData, showToast, t]);
useEffect(() => {
if (verifyCodeData) {
if (verifyCodeData.success) {
setEmailVerified(true);
showToast({
message: verifyCodeData.message || t('completion.codeVerified'),
severity: 'success',
});
} else {
showToast({
message: verifyCodeData.message || t('completion.invalidCode'),
severity: 'error',
});
setEmailVerified(false);
}
}
}, [verifyCodeData, showToast, t]);
useEffect(() => {
if (submitData) {
showToast({
message:
submitData.message ||
t(
submitData.success
? 'completion.submitSuccess'
: 'completion.submitError',
),
severity: submitData.success ? 'success' : 'error',
});
} else if (submitError) {
showToast({
message: getErrorMessage(submitError) || t('completion.problem'),
severity: 'error',
});
}
}, [submitData, submitError, showToast, t]);
useEffect(() => {
setShowPasswordValidations(password ? !validPassword : false);
}, [password, validPassword]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (buttonState === 'counting' && countdown > 0) {
timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
setButtonState('default');
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => clearInterval(timer);
}, [buttonState, countdown]);
const handleSendCode = () => {
sendCode({ email });
};
const handleVerifyCode = () => {
if (!verificationCode.trim()) {
showToast({
message: 'Please enter the verification code',
severity: 'warning',
});
return;
}
verifyCode({ email, otpCode: verificationCode });
};
const handleSubmit = () => {
submitForm({
userId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
firstName,
lastName,
gender: sex,
nationalId,
savePassword: showPasswordSection,
password: showPasswordSection ? password : undefined,
saveEmail: showEmail,
email: showEmail ? email : undefined,
birthDate,
country,
});
};
const getButtonLabel = () => {
if (buttonState === 'counting') {
const m = String(Math.floor(countdown / 60)).padStart(2, '0');
const s = String(countdown % 60).padStart(2, '0');
return toLocaleDigits(`${m}:${s}`, i18n.language);
}
return t('completion.vericationCodeButton');
};
const handleEditEmail = () => {
setButtonState('default');
setCodeSent(false);
setEmailVerified(false);
setVerificationCode('');
};
return (
<Box
sx={{
backgroundColor: 'background.default',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
p: { xs: 1, sm: 2, md: 3 },
}}
>
<Box sx={{ mb: 2 }}>
<Logo />
</Box>
<Box
sx={{
width: '100%',
maxWidth: 730,
backgroundColor: 'background.paper',
border: '1px solid #ccc',
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 4, md: 6 },
py: { xs: 3, sm: 4 },
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="h5" color="text.primary">
{t('completion.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('completion.description')}
</Typography>
</Box>
<PersonalInfoFields
firstName={firstName}
setFirstName={setFirstName}
lastName={lastName}
setLastName={setLastName}
nationalId={nationalId}
setNationalId={setNationalId}
birthDate={birthDate}
setBirthDate={setBirthDate}
sex={sex}
setSex={setSex}
country={country}
setCountry={setCountry}
/>
<PasswordSection
showPasswordSection={showPasswordSection}
setShowPasswordSection={setShowPasswordSection}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
matchPassword={matchPassword}
hasNumber={hasNumber}
hasMinLength={hasMinLength}
hasUpperAndLower={hasUpperAndLower}
hasSpecialChar={hasSpecialChar}
validPassword={validPassword}
showValidations={showPasswordValidations}
/>
<EmailSection
showEmail={showEmail}
setShowEmail={setShowEmail}
email={email}
setEmail={setEmail}
correctEmail={correctEmail}
codeSent={codeSent}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
buttonState={buttonState}
getButtonLabel={getButtonLabel}
handleSendCode={handleSendCode}
handleVerifyCode={handleVerifyCode}
emailVerified={emailVerified}
loading={isVerifyingCode}
handleEditEmail={handleEditEmail}
/>
<SubmitSection
onSubmit={handleSubmit}
loading={isSubmitting}
error={getErrorMessage(submitError)}
success={!!submitData?.success}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,271 @@
export interface Country {
code: string;
label: string;
phone: string;
}
export interface Country {
code: string;
label: string;
phone: string;
}
export const countries: readonly Country[] = [
{ code: 'AF', label: 'country.afghanistan', phone: '+93' },
{ code: 'AX', label: 'country.aland_islands', phone: '+358' },
{ code: 'AL', label: 'country.albania', phone: '+355' },
{ code: 'DZ', label: 'country.algeria', phone: '+213' },
{ code: 'AS', label: 'country.american_samoa', phone: '+1684' },
{ code: 'AD', label: 'country.andorra', phone: '+376' },
{ code: 'AO', label: 'country.angola', phone: '+244' },
{ code: 'AI', label: 'country.anguilla', phone: '+1264' },
{ code: 'AQ', label: 'country.antarctica', phone: '+672' },
{ code: 'AG', label: 'country.antigua_and_barbuda', phone: '+1268' },
{ code: 'AR', label: 'country.argentina', phone: '+54' },
{ code: 'AM', label: 'country.armenia', phone: '+374' },
{ code: 'AW', label: 'country.aruba', phone: '+297' },
{ code: 'AU', label: 'country.australia', phone: '+61' },
{ code: 'AT', label: 'country.austria', phone: '+43' },
{ code: 'AZ', label: 'country.azerbaijan', phone: '+994' },
{ code: 'BS', label: 'country.bahamas', phone: '+1242' },
{ code: 'BH', label: 'country.bahrain', phone: '+973' },
{ code: 'BD', label: 'country.bangladesh', phone: '+880' },
{ code: 'BB', label: 'country.barbados', phone: '+1246' },
{ code: 'BY', label: 'country.belarus', phone: '+375' },
{ code: 'BE', label: 'country.belgium', phone: '+32' },
{ code: 'BZ', label: 'country.belize', phone: '+501' },
{ code: 'BJ', label: 'country.benin', phone: '+229' },
{ code: 'BM', label: 'country.bermuda', phone: '+1441' },
{ code: 'BT', label: 'country.bhutan', phone: '+975' },
{ code: 'BO', label: 'country.bolivia', phone: '+591' },
{ code: 'BA', label: 'country.bosnia_and_herzegovina', phone: '+387' },
{ code: 'BW', label: 'country.botswana', phone: '+267' },
{ code: 'BR', label: 'country.brazil', phone: '+55' },
{
code: 'IO',
label: 'country.british_indian_ocean_territory',
phone: '+246',
},
{ code: 'VG', label: 'country.british_virgin_islands', phone: '+1284' },
{ code: 'BN', label: 'country.brunei', phone: '+673' },
{ code: 'BG', label: 'country.bulgaria', phone: '+359' },
{ code: 'BF', label: 'country.burkina_faso', phone: '+226' },
{ code: 'BI', label: 'country.burundi', phone: '+257' },
{ code: 'KH', label: 'country.cambodia', phone: '+855' },
{ code: 'CM', label: 'country.cameroon', phone: '+237' },
{ code: 'CA', label: 'country.canada', phone: '+1' },
{ code: 'CV', label: 'country.cape_verde', phone: '+238' },
{ code: 'KY', label: 'country.cayman_islands', phone: '+1345' },
{ code: 'CF', label: 'country.central_african_republic', phone: '+236' },
{ code: 'TD', label: 'country.chad', phone: '+235' },
{ code: 'CL', label: 'country.chile', phone: '+56' },
{ code: 'CN', label: 'country.china', phone: '+86' },
{ code: 'CX', label: 'country.christmas_island', phone: '+61' },
{ code: 'CC', label: 'country.cocos_keeling_islands', phone: '+61' },
{ code: 'CO', label: 'country.colombia', phone: '+57' },
{ code: 'KM', label: 'country.comoros', phone: '+269' },
{ code: 'CG', label: 'country.congo_brazzaville', phone: '+242' },
{ code: 'CD', label: 'country.congo_kinshasa', phone: '+243' },
{ code: 'CK', label: 'country.cook_islands', phone: '+682' },
{ code: 'CR', label: 'country.costa_rica', phone: '+506' },
{ code: 'CI', label: 'country.cote_divoire', phone: '+225' },
{ code: 'HR', label: 'country.croatia', phone: '+385' },
{ code: 'CU', label: 'country.cuba', phone: '+53' },
{ code: 'CW', label: 'country.curacao', phone: '+599' },
{ code: 'CY', label: 'country.cyprus', phone: '+357' },
{ code: 'CZ', label: 'country.czech_republic', phone: '+420' },
{ code: 'DK', label: 'country.denmark', phone: '+45' },
{ code: 'DJ', label: 'country.djibouti', phone: '+253' },
{ code: 'DM', label: 'country.dominica', phone: '+1767' },
{ code: 'DO', label: 'country.dominican_republic', phone: '+1' },
{ code: 'EC', label: 'country.ecuador', phone: '+593' },
{ code: 'EG', label: 'country.egypt', phone: '+20' },
{ code: 'SV', label: 'country.el_salvador', phone: '+503' },
{ code: 'GQ', label: 'country.equatorial_guinea', phone: '+240' },
{ code: 'ER', label: 'country.eritrea', phone: '+291' },
{ code: 'EE', label: 'country.estonia', phone: '+372' },
{ code: 'SZ', label: 'country.eswatini', phone: '+268' },
{ code: 'ET', label: 'country.ethiopia', phone: '+251' },
{ code: 'FK', label: 'country.falkland_islands', phone: '+500' },
{ code: 'FO', label: 'country.faroe_islands', phone: '+298' },
{ code: 'FJ', label: 'country.fiji', phone: '+679' },
{ code: 'FI', label: 'country.finland', phone: '+358' },
{ code: 'FR', label: 'country.france', phone: '+33' },
{ code: 'GF', label: 'country.french_guiana', phone: '+594' },
{ code: 'PF', label: 'country.french_polynesia', phone: '+689' },
{ code: 'GA', label: 'country.gabon', phone: '+241' },
{ code: 'GM', label: 'country.gambia', phone: '+220' },
{ code: 'GE', label: 'country.georgia', phone: '+995' },
{ code: 'DE', label: 'country.germany', phone: '+49' },
{ code: 'GH', label: 'country.ghana', phone: '+233' },
{ code: 'GI', label: 'country.gibraltar', phone: '+350' },
{ code: 'GR', label: 'country.greece', phone: '+30' },
{ code: 'GL', label: 'country.greenland', phone: '+299' },
{ code: 'GD', label: 'country.grenada', phone: '+1473' },
{ code: 'GP', label: 'country.guadeloupe', phone: '+590' },
{ code: 'GU', label: 'country.guam', phone: '+1671' },
{ code: 'GT', label: 'country.guatemala', phone: '+502' },
{ code: 'GG', label: 'country.guernsey', phone: '+44' },
{ code: 'GN', label: 'country.guinea', phone: '+224' },
{ code: 'GW', label: 'country.guinea_bissau', phone: '+245' },
{ code: 'GY', label: 'country.guyana', phone: '+592' },
{ code: 'HT', label: 'country.haiti', phone: '+509' },
{ code: 'HN', label: 'country.honduras', phone: '+504' },
{ code: 'HK', label: 'country.hong_kong', phone: '+852' },
{ code: 'HU', label: 'country.hungary', phone: '+36' },
{ code: 'IS', label: 'country.iceland', phone: '+354' },
{ code: 'IN', label: 'country.india', phone: '+91' },
{ code: 'ID', label: 'country.indonesia', phone: '+62' },
{ code: 'IR', label: 'country.iran', phone: '+98' },
{ code: 'IQ', label: 'country.iraq', phone: '+964' },
{ code: 'IE', label: 'country.ireland', phone: '+353' },
{ code: 'IM', label: 'country.isle_of_man', phone: '+44' },
{ code: 'IL', label: 'country.israel', phone: '+972' },
{ code: 'IT', label: 'country.italy', phone: '+39' },
{ code: 'JM', label: 'country.jamaica', phone: '+1876' },
{ code: 'JP', label: 'country.japan', phone: '+81' },
{ code: 'JE', label: 'country.jersey', phone: '+44' },
{ code: 'JO', label: 'country.jordan', phone: '+962' },
{ code: 'KZ', label: 'country.kazakhstan', phone: '+7' },
{ code: 'KE', label: 'country.kenya', phone: '+254' },
{ code: 'KI', label: 'country.kiribati', phone: '+686' },
{ code: 'XK', label: 'country.kosovo', phone: '+383' },
{ code: 'KW', label: 'country.kuwait', phone: '+965' },
{ code: 'KG', label: 'country.kyrgyzstan', phone: '+996' },
{ code: 'LA', label: 'country.laos', phone: '+856' },
{ code: 'LV', label: 'country.latvia', phone: '+371' },
{ code: 'LB', label: 'country.lebanon', phone: '+961' },
{ code: 'LS', label: 'country.lesotho', phone: '+266' },
{ code: 'LR', label: 'country.liberia', phone: '+231' },
{ code: 'LY', label: 'country.libya', phone: '+218' },
{ code: 'LI', label: 'country.liechtenstein', phone: '+423' },
{ code: 'LT', label: 'country.lithuania', phone: '+370' },
{ code: 'LU', label: 'country.luxembourg', phone: '+352' },
{ code: 'MO', label: 'country.macau', phone: '+853' },
{ code: 'MG', label: 'country.madagascar', phone: '+261' },
{ code: 'MW', label: 'country.malawi', phone: '+265' },
{ code: 'MY', label: 'country.malaysia', phone: '+60' },
{ code: 'MV', label: 'country.maldives', phone: '+960' },
{ code: 'ML', label: 'country.mali', phone: '+223' },
{ code: 'MT', label: 'country.malta', phone: '+356' },
{ code: 'MH', label: 'country.marshall_islands', phone: '+692' },
{ code: 'MQ', label: 'country.martinique', phone: '+596' },
{ code: 'MR', label: 'country.mauritania', phone: '+222' },
{ code: 'MU', label: 'country.mauritius', phone: '+230' },
{ code: 'YT', label: 'country.mayotte', phone: '+262' },
{ code: 'MX', label: 'country.mexico', phone: '+52' },
{ code: 'FM', label: 'country.micronesia', phone: '+691' },
{ code: 'MD', label: 'country.moldova', phone: '+373' },
{ code: 'MC', label: 'country.monaco', phone: '+377' },
{ code: 'MN', label: 'country.mongolia', phone: '+976' },
{ code: 'ME', label: 'country.montenegro', phone: '+382' },
{ code: 'MS', label: 'country.montserrat', phone: '+1664' },
{ code: 'MA', label: 'country.morocco', phone: '+212' },
{ code: 'MZ', label: 'country.mozambique', phone: '+258' },
{ code: 'MM', label: 'country.myanmar', phone: '+95' },
{ code: 'NA', label: 'country.namibia', phone: '+264' },
{ code: 'NR', label: 'country.nauru', phone: '+674' },
{ code: 'NP', label: 'country.nepal', phone: '+977' },
{ code: 'NL', label: 'country.netherlands', phone: '+31' },
{ code: 'NC', label: 'country.new_caledonia', phone: '+687' },
{ code: 'NZ', label: 'country.new_zealand', phone: '+64' },
{ code: 'NI', label: 'country.nicaragua', phone: '+505' },
{ code: 'NE', label: 'country.niger', phone: '+227' },
{ code: 'NG', label: 'country.nigeria', phone: '+234' },
{ code: 'NU', label: 'country.niue', phone: '+683' },
{ code: 'NF', label: 'country.norfolk_island', phone: '+672' },
{ code: 'KP', label: 'country.north_korea', phone: '+850' },
{ code: 'MK', label: 'country.north_macedonia', phone: '+389' },
{ code: 'MP', label: 'country.northern_mariana_islands', phone: '+1670' },
{ code: 'NO', label: 'country.norway', phone: '+47' },
{ code: 'OM', label: 'country.oman', phone: '+968' },
{ code: 'PK', label: 'country.pakistan', phone: '+92' },
{ code: 'PW', label: 'country.palau', phone: '+680' },
{ code: 'PS', label: 'country.palestine', phone: '+970' },
{ code: 'PA', label: 'country.panama', phone: '+507' },
{ code: 'PG', label: 'country.papua_new_guinea', phone: '+675' },
{ code: 'PY', label: 'country.paraguay', phone: '+595' },
{ code: 'PE', label: 'country.peru', phone: '+51' },
{ code: 'PH', label: 'country.philippines', phone: '+63' },
{ code: 'PN', label: 'country.pitcairn_islands', phone: '+64' },
{ code: 'PL', label: 'country.poland', phone: '+48' },
{ code: 'PT', label: 'country.portugal', phone: '+351' },
{ code: 'PR', label: 'country.puerto_rico', phone: '+1' },
{ code: 'QA', label: 'country.qatar', phone: '+974' },
{ code: 'RE', label: 'country.reunion', phone: '+262' },
{ code: 'RO', label: 'country.romania', phone: '+40' },
{ code: 'RU', label: 'country.russia', phone: '+7' },
{ code: 'RW', label: 'country.rwanda', phone: '+250' },
{ code: 'BL', label: 'country.saint_barthelemy', phone: '+590' },
{ code: 'SH', label: 'country.saint_helena', phone: '+290' },
{ code: 'KN', label: 'country.saint_kitts_and_nevis', phone: '+1869' },
{ code: 'LC', label: 'country.saint_lucia', phone: '+1758' },
{ code: 'MF', label: 'country.saint_martin', phone: '+590' },
{ code: 'PM', label: 'country.saint_pierre_and_miquelon', phone: '+508' },
{
code: 'VC',
label: 'country.saint_vincent_and_the_grenadines',
phone: '+1784',
},
{ code: 'WS', label: 'country.samoa', phone: '+685' },
{ code: 'SM', label: 'country.san_marino', phone: '+378' },
{ code: 'ST', label: 'country.sao_tome_and_principe', phone: '+239' },
{ code: 'SA', label: 'country.saudi_arabia', phone: '+966' },
{ code: 'SN', label: 'country.senegal', phone: '+221' },
{ code: 'RS', label: 'country.serbia', phone: '+381' },
{ code: 'SC', label: 'country.seychelles', phone: '+248' },
{ code: 'SL', label: 'country.sierra_leone', phone: '+232' },
{ code: 'SG', label: 'country.singapore', phone: '+65' },
{ code: 'SX', label: 'country.sint_maarten', phone: '+1721' },
{ code: 'SK', label: 'country.slovakia', phone: '+421' },
{ code: 'SI', label: 'country.slovenia', phone: '+386' },
{ code: 'SB', label: 'country.solomon_islands', phone: '+677' },
{ code: 'SO', label: 'country.somalia', phone: '+252' },
{ code: 'ZA', label: 'country.south_africa', phone: '+27' },
{
code: 'GS',
label: 'country.south_georgia_and_south_sandwich_islands',
phone: '+500',
},
{ code: 'KR', label: 'country.south_korea', phone: '+82' },
{ code: 'SS', label: 'country.south_sudan', phone: '+211' },
{ code: 'ES', label: 'country.spain', phone: '+34' },
{ code: 'LK', label: 'country.sri_lanka', phone: '+94' },
{ code: 'SD', label: 'country.sudan', phone: '+249' },
{ code: 'SR', label: 'country.suriname', phone: '+597' },
{ code: 'SJ', label: 'country.svalbard_and_jan_mayen', phone: '+47' },
{ code: 'SE', label: 'country.sweden', phone: '+46' },
{ code: 'CH', label: 'country.switzerland', phone: '+41' },
{ code: 'SY', label: 'country.syria', phone: '+963' },
{ code: 'TW', label: 'country.taiwan', phone: '+886' },
{ code: 'TJ', label: 'country.tajikistan', phone: '+992' },
{ code: 'TZ', label: 'country.tanzania', phone: '+255' },
{ code: 'TH', label: 'country.thailand', phone: '+66' },
{ code: 'TL', label: 'country.timor_leste', phone: '+670' },
{ code: 'TG', label: 'country.togo', phone: '+228' },
{ code: 'TK', label: 'country.tokelau', phone: '+690' },
{ code: 'TO', label: 'country.tonga', phone: '+676' },
{ code: 'TT', label: 'country.trinidad_and_tobago', phone: '+1868' },
{ code: 'TN', label: 'country.tunisia', phone: '+216' },
{ code: 'TR', label: 'country.turkey', phone: '+90' },
{ code: 'TM', label: 'country.turkmenistan', phone: '+993' },
{ code: 'TC', label: 'country.turks_and_caicos_islands', phone: '+1649' },
{ code: 'TV', label: 'country.tuvalu', phone: '+688' },
{ code: 'VI', label: 'country.us_virgin_islands', phone: '+1340' },
{ code: 'UG', label: 'country.uganda', phone: '+256' },
{ code: 'UA', label: 'country.ukraine', phone: '+380' },
{ code: 'AE', label: 'country.united_arab_emirates', phone: '+971' },
{ code: 'GB', label: 'country.united_kingdom', phone: '+44' },
{ code: 'US', label: 'country.united_states', phone: '+1' },
{ code: 'UY', label: 'country.uruguay', phone: '+598' },
{ code: 'UZ', label: 'country.uzbekistan', phone: '+998' },
{ code: 'VU', label: 'country.vanuatu', phone: '+678' },
{ code: 'VA', label: 'country.vatican_city', phone: '+39' },
{ code: 'VE', label: 'country.venezuela', phone: '+58' },
{ code: 'VN', label: 'country.vietnam', phone: '+84' },
{ code: 'WF', label: 'country.wallis_and_futuna', phone: '+681' },
{ code: 'EH', label: 'country.western_sahara', phone: '+212' },
{ code: 'YE', label: 'country.yemen', phone: '+967' },
{ code: 'ZM', label: 'country.zambia', phone: '+260' },
{ code: 'ZW', label: 'country.zimbabwe', phone: '+263' },
];

View File

@@ -0,0 +1,32 @@
export interface GenericApiResponse {
success: boolean;
message: string;
errorCode?: number;
}
export interface SendEmailOtpPayload {
email: string;
}
export interface ConfirmEmailOtpPayload {
email: string;
otpCode: string;
}
export interface CompleteUserInfoPayload {
userId: string;
firstName: string;
lastName: string;
gender: 0 | 1 | 2;
nationalId: string;
birthDate: Date | null;
country: string;
savePassword?: boolean;
password?: string;
saveEmail?: boolean;
email?: string;
}
export interface CompleteUserInfoResponse extends GenericApiResponse {
validations: { property: string; message: string }[] | null;
}

View File

@@ -0,0 +1,73 @@
import { type Dispatch, type SetStateAction } from 'react';
export enum Gender {
None = 0,
Female = 1,
Male = 2,
}
export interface DateOfBirthProps {
value: Date | null;
onChange: (date: Date | null) => void;
}
export interface EmailSectionProps {
showEmail: boolean;
setShowEmail: (checked: boolean) => void;
email: string;
setEmail: (email: string) => void;
correctEmail: boolean;
codeSent: boolean;
verificationCode: string;
setVerificationCode: (code: string) => void;
buttonState: 'default' | 'counting' | 'sent';
getButtonLabel: () => string;
handleSendCode: () => void;
handleVerifyCode: () => void;
emailVerified: boolean;
loading: boolean;
handleEditEmail: () => void;
}
export interface PasswordSectionProps {
showPasswordSection: boolean;
setShowPasswordSection: (checked: boolean) => void;
password: string;
setPassword: (password: string) => void;
confirmPassword: string;
setConfirmPassword: (confirmPassword: string) => void;
matchPassword: boolean;
hasNumber: boolean;
hasMinLength: boolean;
hasUpperAndLower: boolean;
hasSpecialChar: boolean;
validPassword: boolean;
showValidations: boolean;
}
export interface ValidationItemProps {
isValid: boolean;
label: string;
}
export interface PersonalInfoFieldsProps {
firstName: string;
setFirstName: (v: string) => void;
lastName: string;
setLastName: (v: string) => void;
sex: Gender;
setSex: Dispatch<SetStateAction<Gender>>;
country: string;
setCountry: (country: string) => void;
nationalId: string;
setNationalId: (v: string) => void;
birthDate: Date | null;
setBirthDate: (d: Date | null) => void;
}
export interface SubmitProps {
onSubmit: () => void;
loading: boolean;
error: string | null;
success: boolean;
}

View File

@@ -0,0 +1,173 @@
import apiClient from '@/lib/apiClient';
import {
type GetProfileApiResponse,
type SaveProfileApiResponse,
type PhoneNumberApiResponse,
type ConfirmPhoneNumberApiResponse,
type SaveSettingsApiResponse,
type DeleteSessionsApiResponse,
type PasswordApiResponse,
type SendEmailCodeApiResponse,
type ConfirmEmailCodeApiResponse,
type ChangeEmailApiResponse,
} from '../types/settingsApiType';
import { type InfoRowData } from '../types/settingsType';
export async function fetchProfile(): Promise<{ data: GetProfileApiResponse }> {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
);
return { data: res.data };
}
export async function saveProfile(payload?: {
data: InfoRowData;
imageUrl: string | null;
}): Promise<{ data: SaveProfileApiResponse }> {
if (!payload) {
throw new Error('Payload for saving profile is missing.');
}
const res = await apiClient.post<SaveProfileApiResponse>(
'/Profile/SaveProfilePersonalInforamtion',
payload,
);
return { data: res.data };
}
export async function sendVerificationCode(payload?: {
phoneNumber: string;
}): Promise<{ data: PhoneNumberApiResponse }> {
if (!payload) {
throw new Error('Payload for sending verification code is missing.');
}
const res = await apiClient.post<PhoneNumberApiResponse>(
'/Profile/SendVerfiyPhoneNumberCode',
payload,
);
return { data: res.data };
}
export async function confirmPhoneNumberCode(payload?: {
phoneNumber: string;
verifyCode: string;
}): Promise<{ data: ConfirmPhoneNumberApiResponse }> {
if (!payload) {
throw new Error('Payload for confirming phone number is missing.');
}
const res = await apiClient.post<ConfirmPhoneNumberApiResponse>(
'/Profile/ConfirmPhoneNumberChangeCode',
payload,
);
return { data: res.data };
}
export async function changePhoneNumber(payload?: {
phoneNumber: string;
}): Promise<{ data: PhoneNumberApiResponse }> {
if (!payload) {
throw new Error('Payload for changing phone number is missing.');
}
const res = await apiClient.post<PhoneNumberApiResponse>(
'/Profile/ChangePhoneNumber',
payload,
);
return { data: res.data };
}
export async function saveSettings(payload?: {
theme: number;
calendarType: number;
language: number;
}): Promise<{ data: SaveSettingsApiResponse }> {
if (!payload) {
throw new Error('Payload for saving settings is missing.');
}
const res = await apiClient.post<SaveSettingsApiResponse>(
'/Profile/SaveSetting',
payload,
);
return { data: res.data };
}
export async function deleteSessions(payload?: {
keys: string[];
}): Promise<{ data: DeleteSessionsApiResponse }> {
if (!payload || payload.keys.length === 0) {
throw new Error('Payload with session keys is missing or empty.');
}
const res = await apiClient.post<DeleteSessionsApiResponse>(
'/Profile/DeleteSessions',
payload,
);
return { data: res.data };
}
export async function addPassword(payload?: {
password: string;
}): Promise<{ data: PasswordApiResponse }> {
if (!payload) {
throw new Error('Payload for adding password is missing.');
}
const res = await apiClient.post<PasswordApiResponse>(
'/Profile/AddPassword',
payload,
);
return { data: res.data };
}
export async function changePassword(payload?: {
oldPassword: string;
newPassword: string;
}): Promise<{ data: PasswordApiResponse }> {
if (!payload) {
throw new Error('Payload for changing password is missing.');
}
const res = await apiClient.post<PasswordApiResponse>(
'/Profile/ChangePassword',
payload,
);
return { data: res.data };
}
// ✅ NEW FUNCTIONS
export async function sendEmailCode(payload?: {
email: string;
}): Promise<{ data: SendEmailCodeApiResponse }> {
if (!payload) {
throw new Error('Payload for sending email code is missing.');
}
const res = await apiClient.post<SendEmailCodeApiResponse>(
'Profile/SendEmailChangeCode',
payload,
);
return { data: res.data };
}
export async function confirmEmailCode(payload?: {
email: string;
verifyCode: string;
}): Promise<{ data: ConfirmEmailCodeApiResponse }> {
if (!payload) {
throw new Error('Payload for confirming email code is missing.');
}
const res = await apiClient.post<ConfirmEmailCodeApiResponse>(
'Profile/ConfirmEmailChangeCode',
payload,
);
return { data: res.data };
}
export async function changeEmail(payload?: {
email: string;
}): Promise<{ data: ChangeEmailApiResponse }> {
if (!payload) {
throw new Error('Payload for changing email is missing.');
}
const res = await apiClient.post<ChangeEmailApiResponse>(
'Profile/ChangeEmail',
payload,
);
return { data: res.data };
}

View File

@@ -0,0 +1,254 @@
import {
Box,
InputAdornment,
ListItem,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
TextField,
Typography,
} from '@mui/material';
import { useMemo, useRef, useState, type RefObject } from 'react';
import { ArrowDown2 } from 'iconsax-react';
import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next';
import { countries, type Country } from '../data/countries';
interface CountryCodeSelectorProps {
show: boolean;
value: string;
onChange: (newValue: string) => void;
menuAnchor: HTMLElement | null;
onCloseFocusRef: RefObject<HTMLInputElement | null>;
}
/**
* An animated country code adornment that fades and slides into view.
* Its visibility is controlled by the `show` prop.
*/
export function CountryCodeSelector({
show,
value,
onChange,
menuAnchor,
onCloseFocusRef,
}: CountryCodeSelectorProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const open = Boolean(anchorEl);
const searchInputRef = useRef<HTMLInputElement>(null);
const menuWidth = menuAnchor ? menuAnchor.clientWidth : 'auto';
const { t, i18n } = useTranslation();
const selectedCountry =
countries.find((c) => c.phone === value) || countries[0];
const handleClick = () => {
setAnchorEl(menuAnchor);
};
const handleClose = () => {
setTimeout(() => {
setAnchorEl(null);
}, 0);
setTimeout(() => {
onCloseFocusRef.current?.focus();
}, 100);
setSearchTerm(''); // Reset search on close
};
const handleSelect = (country: Country) => {
onChange(country.phone);
handleClose();
};
const handleMenuEntered = () => {
// Focus the input field after the menu has finished opening
searchInputRef.current?.focus();
};
const filteredCountries = useMemo(
() =>
countries.filter(
(country) =>
t(country.label).toLowerCase().includes(searchTerm.toLowerCase()) ||
country.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
country.phone.includes(searchTerm),
),
[searchTerm, t],
);
return (
<InputAdornment
position={i18n.dir() === 'rtl' ? 'start' : 'end'}
sx={{
mx: 0,
}}
>
<Box
onClick={handleClick}
sx={{
// Animate width and opacity based on the 'show' prop
width: show ? 'auto' : 0,
opacity: show ? 1 : 0,
transition: (theme) =>
theme.transitions.create(['width', 'opacity'], {
duration: theme.transitions.duration.standard,
}),
// Prevent content from wrapping or spilling out during animation
overflow: 'hidden',
whiteSpace: 'nowrap',
// layout styles
height: '100%',
display: 'flex',
alignItems: 'center',
gap: 0.25,
pl: show ? 0.25 : 0,
'&:hover': {
cursor: 'pointer',
},
}}
>
{/* This inner Box prevents the content from being squeezed during the transition */}
<ArrowDown2 size="24" variant="Bold" />
<Typography
variant="body1"
color="text.primary"
component="div"
sx={{ direction: 'rtl' }} // TODO: need to fixed for both en and fa
>
{value}
</Typography>
<ReactCountryFlag
countryCode={selectedCountry.code}
svg
style={{
height: '1.5rem',
width: '1.5rem',
// TODO: Check alignment for better styling definition
marginTop: '-2px',
marginRight: '4px',
}}
/>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
slotProps={{
list: {
sx: {
// Set the width to match the anchor element's width
width: menuWidth,
height: '18.75rem',
},
},
transition: {
onEntered: handleMenuEntered,
},
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
p: 1,
backgroundColor: 'background.paper',
}}
>
<TextField
inputRef={searchInputRef}
size="small"
fullWidth
label={t('labels.search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</Box>
{/* Can improve preformance with using virtual scrolling */}
<Box sx={{ width: '100%', maxHeight: '14.75rem', overflow: 'auto' }}>
{filteredCountries.length === 0 ? (
<MenuItem disabled>
<ListItem>
<ListItemText>{t('messages.noResualtFound')}</ListItemText>
</ListItem>
</MenuItem>
) : (
filteredCountries.map((country) => (
<MenuItem
key={country.code}
selected={country.phone === value}
onClick={() => handleSelect(country)}
>
<ListItemIcon>
<ReactCountryFlag
countryCode={country.code}
svg
style={{
height: '1.125rem',
width: '1.125rem',
}}
/>
</ListItemIcon>
<ListItemText primary={t(country.label)} />
<Typography color="text.secondary">
{country.phone}
</Typography>
</MenuItem>
))
)}
</Box>
{/* virtual scrolling */}
{/* <Virtuoso
style={{ height: '14.75rem' }} // Adjust height to account for the search bar
data={filteredCountries}
components={{
EmptyPlaceholder: () => (
<MenuItem disabled>
<ListItemText>{t('messages.noResultFound')}</ListItemText>
</MenuItem>
),
}}
initialTopMostItemIndex={countries.indexOf(selectedCountry)}
itemContent={(_, country) => (
<MenuItem
key={country.code}
selected={country.phone === value}
onClick={() => handleSelect(country)}
>
<ListItemIcon>
<ReactCountryFlag
countryCode={country.code}
svg
style={{ fontSize: '1.25em', lineHeight: '1.25em' }}
/>
</ListItemIcon>
<ListItemText primary={country.label} />
<Typography variant="body2" color="text.secondary">
{country.phone}
</Typography>
</MenuItem>
)}
/> */}
</Menu>
</Box>
</InputAdornment>
);
}

View File

@@ -0,0 +1,21 @@
import { Box } from '@mui/material';
import React from 'react';
interface PageWrapperProps {
children: React.ReactNode;
}
export function PageWrapper({ children }: PageWrapperProps) {
return (
<Box
sx={{
mx: 'auto',
backgroundColor: 'background.paper',
display: 'flex',
flexDirection: 'column',
}}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,285 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
useTheme,
useMediaQuery,
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { DeviceMessage, Logout } from 'iconsax-react';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import { fetchProfile, deleteSessions } from '../../api/settingsApi';
import { type ApiSession } from '../../types/settingsApiType';
import { useManualApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate';
import { type Device } from '../../types/settingsType';
export function ActiveDevices() {
const { t, i18n } = useTranslation('setting');
const [devices, setDevices] = useState<Device[]>([]);
const [loadingDeleteIds, setLoadingDeleteIds] = useState<string[]>([]);
const theme = useTheme();
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: terminateData,
loading: isTerminating,
execute: executeTerminateAll,
} = useManualApi(deleteSessions);
useEffect(() => {
executeFetchProfile();
}, [i18n.language]);
useEffect(() => {
if (profileData?.success && profileData.activeSessions) {
const { sessions, currentKey } = profileData.activeSessions;
const formattedDevices = sessions.map((session: ApiSession) => ({
id: session.key,
timeAndDate: formatDate(session.created, i18n.language, t),
deviceModel: `${session.deviceOs} ${session.deviceName}`,
ip: session.ipAddress,
current: session.key === currentKey,
}));
setDevices(formattedDevices);
}
}, [profileData, i18n.language, t]);
useEffect(() => {
if (terminateData?.success) {
setDevices((prev) => prev.filter((d) => d.current));
}
}, [terminateData]);
const handleDeleteDevice = async (id: string) => {
if (loadingDeleteIds.includes(id)) return;
setLoadingDeleteIds((prev) => [...prev, id]);
try {
const { data } = await deleteSessions({ keys: [id] });
if (data.success) {
setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id));
} else {
console.error('Delete failed:', data.message);
}
} catch (error: unknown) {
console.error('Delete error:', error);
} finally {
setLoadingDeleteIds((prev) =>
prev.filter((loadingId) => loadingId !== id),
);
}
};
const handleTerminateAllOtherSessions = () => {
const otherSessionKeys = devices.filter((d) => !d.current).map((d) => d.id);
if (otherSessionKeys.length > 0) {
executeTerminateAll({ keys: otherSessionKeys });
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('active.activeDevices')}
subtitle={t('active.activeDevicesCaption')}
action={
<Button
size="medium"
variant="outlined"
onClick={handleTerminateAllOtherSessions}
sx={{
borderRadius: 1,
borderColor: 'error.main',
color: 'error.main',
textTransform: 'none',
}}
disabled={isLoading || isTerminating}
>
{isTerminating
? t('active.deleting')
: t('active.deleteDevicesButton')}
</Button>
}
>
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
}}
>
{devices.map((device) => (
<React.Fragment key={device.id}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
minHeight: 50,
py: 1.5,
}}
>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 1, sm: 1 },
}}
>
{device.timeAndDate}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
width: '100%',
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',
textTransform: 'none',
'&.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" />}
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',
textTransform: 'none',
'& .MuiButton-startIcon': {
marginRight: 0.5,
marginLeft: 0,
},
}}
>
{loadingDeleteIds.includes(device.id) ? (
<CircularProgress size={20} color="error" />
) : (
t('active.deleteDevice')
)}
</Button>
</Box>
</Box>
{isXsup && (
<Box sx={{ borderBottom: 1, borderColor: 'divider' }} />
)}
</React.Fragment>
))}
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,144 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Box,
Button,
Link,
CircularProgress,
Typography,
} from '@mui/material';
import { CloseCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type PasswordDialogProps } from '../../types/settingsType';
export function PasswordDialog({
open,
fullScreen,
handleClose,
password,
setPassword,
confirmPassword,
setConfirmPassword,
currentPassword,
setCurrentPassword,
showValidation,
validPassword,
matchPassword,
loading,
handleSubmit,
changePassword,
apiError,
t,
}: PasswordDialogProps) {
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
fullScreen={fullScreen}
maxWidth="xs"
scroll="body"
PaperProps={{ sx: { mx: 1 } }}
>
<DialogTitle sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton onClick={handleClose}>
<Icon Component={CloseCircle} size="large" color="primary.main" />
</IconButton>
<Box component="span" fontWeight="bold" fontSize={18}>
{changePassword
? t('securityForm.changePassword')
: t('securityForm.addPassword')}
</Box>
</Box>
</DialogTitle>
<DialogContent
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 3 },
}}
>
{/* ✅ 4. API ERROR DISPLAY ADDED */}
{apiError && (
<Typography color="error" variant="body2" textAlign="center">
{apiError}
</Typography>
)}
{changePassword && (
<>
<TextField
label={t('securityForm.currentPassword')}
type="password"
fullWidth
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }}
/>
<Link sx={{ fontSize: '15px', cursor: 'pointer' }}>
{t('securityForm.forgetPassword')}
</Link>
</>
)}
<TextField
label={t('securityForm.newPassword')}
type="password"
fullWidth
value={password}
onChange={(e) => setPassword(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
{showValidation && (
<Typography
variant="body2"
color={validPassword ? 'success.main' : 'error.main'}
>
{validPassword
? t('securityForm.passwordIsValid')
: t('securityForm.passwordIsInvalid')}
</Typography>
)}
<TextField
label={t('securityForm.confirmPassword')}
type="password"
fullWidth
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={confirmPassword.length > 0 && !matchPassword}
helperText={
confirmPassword.length > 0 && !matchPassword
? t('securityForm.notCompatibility')
: ' '
}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'center' }}>
<Button
fullWidth
sx={{ height: 48, textTransform: 'none' }}
variant="contained"
onClick={handleSubmit}
disabled={!validPassword || !matchPassword || loading}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
t('securityForm.confirm')
)}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,191 @@
import { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Button,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { PasswordDialog } from './PasswordDialog';
import { regex } from '@/utils/regex';
import { useManualApi } from '@/hooks/useApi';
import {
addPassword,
changePassword,
fetchProfile,
} from '../../api/settingsApi';
export function PasswordSecurity() {
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useTranslation('setting');
const [open, setOpen] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [showValidation, setShowValidation] = useState(false);
const [showPasswordAlert, setShowPasswordAlert] = useState(false);
const [changePasswordUI, setChangePasswordUI] = useState(false);
const { validPassword } = regex(password);
const matchPassword = password === confirmPassword;
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: addData,
loading: isAdding,
error: addError,
execute: executeAddPassword,
} = useManualApi(addPassword);
const {
data: changeData,
loading: isChanging,
error: changeError,
execute: executeChangePassword,
} = useManualApi(changePassword);
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success) {
setChangePasswordUI(profileData.hasPassword || false);
}
}, [profileData]);
useEffect(() => {
if (password) {
setShowValidation(!validPassword);
} else {
setShowValidation(false);
}
}, [password, validPassword]);
useEffect(() => {
if (addData?.success || changeData?.success) {
setShowPasswordAlert(true);
if (!changePasswordUI) {
setChangePasswordUI(true);
}
setOpen(false);
setPassword('');
setConfirmPassword('');
setCurrentPassword('');
}
}, [addData, changeData, changePasswordUI]);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handlePasswordSubmit = () => {
if (changePasswordUI) {
executeChangePassword({
oldPassword: currentPassword,
newPassword: password,
});
} else {
executeAddPassword({ password });
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const apiError = useMemo(
() => addError || changeError,
[addError, changeError],
);
return (
<PageWrapper>
<CardContainer
title={t('securityForm.password')}
subtitle={t('securityForm.determinePassword')}
action={
<Button variant="contained" onClick={handleOpen}>
{changePasswordUI
? t('securityForm.changePassword')
: t('securityForm.addPassword')}
</Button>
}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '120px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box sx={{ px: 4, py: 2 }}>
{changePasswordUI ? (
<Box>
<Typography variant="h6">
{t('securityForm.activePassword')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('securityForm.lastChange')}
</Typography>
</Box>
) : (
<Typography
variant="body1"
color="text.secondary"
sx={{ textAlign: 'center', py: 4 }}
>
{t('securityForm.notDeterminedPassword')}
</Typography>
)}
<PasswordDialog
open={open}
fullScreen={fullScreen}
handleClose={handleClose}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
currentPassword={currentPassword}
setCurrentPassword={setCurrentPassword}
showValidation={showValidation}
validPassword={validPassword}
matchPassword={matchPassword}
loading={isAdding || isChanging}
handleSubmit={handlePasswordSubmit}
changePassword={changePasswordUI}
apiError={getErrorMessage(apiError)}
t={t}
/>
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,39 @@
import { Box, Typography } from '@mui/material';
import { TickCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type ValidationItemProps } from '../../types/settingsType';
export function PasswordValidationItem({
isValid,
label,
}: ValidationItemProps) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 0.5,
flexWrap: 'wrap',
}}
>
<Icon
Component={TickCircle}
color={isValid ? 'success.main' : 'primary.main'}
variant={isValid ? 'Bold' : 'Outline'}
size="small"
/>
<Typography
variant="body2"
color="text.primary"
sx={{
fontSize: { xs: '0.85rem', sm: '0.875rem' },
wordBreak: 'break-word',
flex: 1,
}}
>
{label}
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { fetchProfile } from '../../api/settingsApi';
import type { LoginLog } from '../../types/settingsApiType';
import { useManualApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate';
export function RecentLogins() {
const { t, i18n } = useTranslation('setting');
const [logs, setLogs] = useState<LoginLog[]>([]);
const theme = useTheme();
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
useEffect(() => {
executeFetchProfile();
}, [i18n.language]);
useEffect(() => {
if (profileData?.success && Array.isArray(profileData.loginLogs)) {
setLogs(profileData.loginLogs);
}
}, [profileData]);
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('securityForm.recentLogins')}
subtitle={t('securityForm.description')}
>
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError) || t('active.errorFetch')}
</Typography>
</Box>
) : (
<Box sx={{ px: 4 }}>
{logs.map((log, index) => (
<React.Fragment key={index}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
minHeight: 50,
py: 1.5,
flexWrap: 'wrap',
gap: 2,
}}
>
<Typography sx={{ flex: 1, minWidth: 160 }}>
{formatDate(log.loginDateTime, i18n.language, t)}
</Typography>
<Typography sx={{ flex: 1, minWidth: 160 }}>
{`${log.deviceOs} ${log.deviceName}`}
</Typography>
<Typography sx={{ flex: 1, minWidth: 120 }}>
{log.ipAddress}
</Typography>
</Box>
{isXsup && index < logs.length - 1 && (
<Box sx={{ borderBottom: 1, borderColor: 'divider' }} />
)}
</React.Fragment>
))}
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,316 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
useColorScheme,
Autocomplete,
TextField,
CircularProgress,
} from '@mui/material';
import { CardContainer } from '@/components/CardContainer';
import { useTranslation } from 'react-i18next';
import { ThemeToggleButton } from '@/components/ThemToggle';
import { PageWrapper } from '../PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import { Sun1, Moon, Calendar1 } from 'iconsax-react';
import { useManualApi } from '@/hooks/useApi';
import { fetchProfile, saveSettings } from '../../api/settingsApi';
type ThemeMode = 'light' | 'dark';
type CalendarType = 'christian' | 'solar' | 'lunar';
interface SettingsState {
language: string;
calendar: CalendarType;
theme: ThemeMode;
}
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() {
const { t, i18n } = useTranslation(['setting']);
const { mode, setMode } = useColorScheme();
const [savedSettings, setSavedSettings] = useState<SettingsState>({
language: i18n.language,
calendar: 'solar',
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
});
const [draftSettings, setDraftSettings] =
useState<SettingsState>(savedSettings);
const [isEditing, setIsEditing] = useState(false);
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: saveData,
loading: isSaving,
error: saveError,
execute: executeSaveSettings,
} = useManualApi(saveSettings);
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success && profileData.userSettings) {
const { theme, calendarType, language } = profileData.userSettings;
const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
1: 'light',
2: 'dark',
};
const newSettings: SettingsState = {
theme: themeReverseMap[theme] || 'light',
calendar:
calendarOptions.find((c) => c.apiValue === calendarType)?.key ||
'solar',
language:
languageOptions.find((l) => l.apiValue === language)?.code || 'en',
};
setSavedSettings(newSettings);
setDraftSettings(newSettings);
setMode(newSettings.theme);
i18n.changeLanguage(newSettings.language);
}
}, [profileData, setMode, i18n]);
useEffect(() => {
if (saveData?.success) {
setMode(draftSettings.theme);
setSavedSettings(draftSettings);
i18n.changeLanguage(draftSettings.language);
setIsEditing(false);
}
}, [saveData, draftSettings, setMode, i18n]);
useEffect(() => {
if (isEditing) {
const resolvedMode = mode === 'light' || mode === 'dark' ? mode : 'light';
setDraftSettings({ ...savedSettings, theme: resolvedMode });
}
}, [isEditing, savedSettings, mode]);
const handleCancel = () => {
setIsEditing(false);
setDraftSettings(savedSettings);
setMode(savedSettings.theme);
};
const handleEditToggle = () => {
if (isEditing) {
const languageObj = languageOptions.find(
(o) => o.code === draftSettings.language,
);
const calendarObj = calendarOptions.find(
(c) => c.key === draftSettings.calendar,
);
if (languageObj && calendarObj) {
executeSaveSettings({
theme: themeApiMap[draftSettings.theme],
calendarType: calendarObj.apiValue,
language: languageObj.apiValue,
});
}
} else {
setIsEditing(true);
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('settings.title')}
subtitle={t('settings.description')}
highlighted={isEditing}
action={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{isEditing && (
<Button
variant="text"
onClick={handleCancel}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
fontSize: { xs: '0.85rem', sm: '1rem' },
}}
disabled={isSaving || isFetching}
>
{t('settings.rejectButton')}
</Button>
)}
<Button
onClick={handleEditToggle}
size="large"
variant={isEditing ? 'contained' : 'outlined'}
disabled={isSaving || isFetching}
>
{isSaving
? t('settings.saving')
: isEditing
? t('settings.saveButton')
: t('settings.editButton')}
</Button>
</Box>
}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{getErrorMessage(saveError) && (
<Typography color="error.main" variant="body2" mb={2}>
{getErrorMessage(saveError)}
</Typography>
)}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 4,
mt: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.theme')}
</Typography>
{isEditing ? (
<ThemeToggleButton
value={draftSettings.theme}
onChange={(newTheme) => {
setDraftSettings((prev) => ({
...prev,
theme: newTheme,
}));
}}
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon
Component={savedSettings.theme === 'light' ? Sun1 : Moon}
size="medium"
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${savedSettings.theme}`)}
</Typography>
</Box>
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.language')}
</Typography>
{isEditing ? (
<Autocomplete
options={languageOptions}
getOptionLabel={(o) => o.label}
value={
languageOptions.find(
(o) => o.code === draftSettings.language,
) // ✅ FIX: Removed '|| null'
}
onChange={(_, v) =>
v &&
setDraftSettings((prev) => ({
...prev,
language: v.code,
}))
}
renderInput={(p) => <TextField {...p} />}
size="small"
fullWidth
disableClearable
/>
) : (
<Typography variant="body1">
{
languageOptions.find(
(o) => o.code === savedSettings.language,
)?.label
}
</Typography>
)}
</Box>
</Box>
<Box sx={{ mt: 2, width: { xs: '100%', sm: 'calc(50% - 16px)' } }}>
<Typography variant="caption" color="text.secondary">
{t('settings.calendar')}
</Typography>
{isEditing ? (
<Autocomplete
options={calendarOptions.map((c) => c.key)}
getOptionLabel={(key) => t(`settings.${key}`)}
value={draftSettings.calendar}
onChange={(_, v) =>
v && setDraftSettings((prev) => ({ ...prev, calendar: v }))
}
renderInput={(params) => <TextField {...params} />}
size="small"
fullWidth
disableClearable
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon Component={Calendar1} size="medium" variant="Bold" />
<Typography variant="body1">
{t(`settings.${savedSettings.calendar}`)}
</Typography>
</Box>
)}
</Box>
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from 'react';
import { Box, Button, Typography, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { ProfileImage } from './personalInformation/ProfileImage';
import { InfoRowDisplay } from './personalInformation/InfoRowDisplay';
import { InfoRowEdit } from './personalInformation/InfoRowEdit';
import { PageWrapper } from '../PageWrapper';
import { useManualApi } from '@/hooks/useApi';
import { Gender, type InfoRowData } from '../../types/settingsType';
import { fetchProfile, saveProfile } from '../../api/settingsApi';
export function PersonalInformation() {
const { t } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
const [data, setData] = useState<InfoRowData>({
firstName: '',
lastName: '',
nationalCode: '',
gender: Gender.None,
country: '',
});
const [originalData, setOriginalData] = useState<InfoRowData | null>(null);
const {
data: profileData,
loading: isLoadingProfile,
error: fetchProfileError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: saveData,
loading: isSavingProfile,
error: saveProfileError,
execute: executeSaveProfile,
} = useManualApi(saveProfile);
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success) {
const fetchedData = {
firstName: profileData.firstName ?? '',
lastName: profileData.lastName ?? '',
nationalCode: profileData.nationalCode ?? '',
gender: Object.values(Gender).includes(profileData.gender as Gender)
? (profileData.gender as Gender)
: Gender.None,
country: profileData.countryCode ?? '',
};
setData(fetchedData);
setOriginalData(fetchedData);
setUploadedImageUrl(profileData.profileImageUrl || null);
}
}, [profileData]);
useEffect(() => {
if (saveData?.success) {
setIsEditing(false);
setOriginalData(data);
}
}, [saveData, data]);
const initials = `${data?.firstName?.trim()[0] || ''}${data?.lastName?.trim()[0] || ''}`;
const handleEditClick = () => {
setIsEditing(true);
setOriginalData(data);
};
const handleCancelClick = () => {
setIsEditing(false);
if (originalData) {
setData(originalData);
}
};
const handleSaveClick = async () => {
if (!data) return;
executeSaveProfile({ data, imageUrl: uploadedImageUrl });
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('settingForm.titlePersonalInfo')}
subtitle={t('settingForm.descriptionPersonalInfo')}
highlighted={isEditing}
action={
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
width: '100%',
}}
>
<Box
sx={{
display: 'flex',
gap: 1,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
{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' },
}}
disabled={isSavingProfile}
>
{isSavingProfile ? (
<CircularProgress size={24} color="inherit" />
) : (
t('settingForm.saveButton')
)}
</Button>
</>
) : (
<Button
onClick={handleEditClick}
size="large"
variant="outlined"
sx={{ borderRadius: 1 }}
disabled={isLoadingProfile}
>
{t('settingForm.editButton')}
</Button>
)}
</Box>
{getErrorMessage(saveProfileError) && (
<Typography
color="error"
sx={{ mt: 1, textAlign: 'right', width: '100%' }}
>
{getErrorMessage(saveProfileError)}
</Typography>
)}
</Box>
}
>
{isLoadingProfile ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchProfileError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">
{getErrorMessage(fetchProfileError) ||
t('settingForm.errorFetch')}
</Typography>
</Box>
) : (
<>
<Box
sx={{
mx: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
}}
>
{isEditing && (
<ProfileImage
initials={initials}
uploadedImageUrl={uploadedImageUrl}
onImageChange={(file) => {
const reader = new FileReader();
reader.onload = () =>
setUploadedImageUrl(reader.result as string);
reader.readAsDataURL(file);
}}
onRemoveImage={() => setUploadedImageUrl(null)}
/>
)}
{data &&
(isEditing ? (
<InfoRowEdit data={data} setData={setData} />
) : (
<InfoRowDisplay
data={data}
uploadedImageUrl={uploadedImageUrl}
initials={initials}
/>
))}
</Box>
</>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,256 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import parsePhoneNumberFromString from 'libphonenumber-js';
import { PageWrapper } from '../PageWrapper';
import { CardContainer } from '@/components/CardContainer';
import PhoneDisplay from './phoneNumber/PhoneDisplay';
import PhoneEditForm from './phoneNumber/PhoneEditForm';
import PhoneActionButtons from './phoneNumber/PhoneActionButtons';
import { CircularProgress, Box, Typography } from '@mui/material';
import { toLocaleDigits } from '@/utils/persianDigit';
import { useManualApi } from '@/hooks/useApi';
import {
fetchProfile,
sendVerificationCode,
confirmPhoneNumberCode,
changePhoneNumber,
} from '../../api/settingsApi';
import { type Phone } from '../../types/settingsType';
export function PhoneNumber() {
const { t, i18n } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [phoneNumber, setPhoneNumber] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [showToast, setShowToast] = useState(false);
const [buttonState, setButtonState] = useState<'default' | 'counting'>(
'default',
);
const [isVerified, setIsVerified] = useState(false);
const [phones, setPhones] = useState<Phone[]>([]);
const [countryCode, setCountryCode] = useState('+98');
const [formError, setFormError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false);
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: sendCodeData,
loading: isSendingCode,
error: sendCodeError,
execute: executeSendCode,
} = useManualApi(sendVerificationCode);
const {
data: confirmData,
loading: isVerifying,
error: confirmError,
execute: executeConfirmCode,
} = useManualApi(confirmPhoneNumberCode);
const {
data: changePhoneData,
loading: isChangingPhone,
error: changePhoneError,
execute: executeChangePhone,
} = useManualApi(changePhoneNumber);
useEffect(() => {
if (!isEditing) {
executeFetchProfile();
}
}, [isEditing]);
useEffect(() => {
if (profileData?.success && profileData.phoneNumber) {
setPhones([
{
phone: profileData.phoneNumber,
time: '',
withCode: profileData.phoneNumber,
},
]);
}
}, [profileData]);
useEffect(() => {
if (sendCodeData?.success) {
setButtonState('counting');
setIsVerified(false);
}
}, [sendCodeData]);
useEffect(() => {
if (confirmData?.success && confirmData.confirm) {
setIsVerified(true);
setShowToast(true);
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
executeChangePhone({ phoneNumber: fullPhoneNumber });
}
}, [confirmData, countryCode, phoneNumber, executeChangePhone]);
useEffect(() => {
if (changePhoneData?.success) {
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
setPhones([
{
phone: phoneNumber,
time: t('settingForm.justNow'),
withCode: fullPhoneNumber,
},
]);
setIsEditing(false);
}
}, [changePhoneData, countryCode, phoneNumber, t]);
const apiError = useMemo(
() => sendCodeError || confirmError || changePhoneError,
[sendCodeError, confirmError, changePhoneError],
);
const getErrorMessage = (error: unknown): string | undefined => {
if (!error) return undefined;
if (error instanceof Error) return error.message;
return String(error);
};
const isPhoneValid = (code: string, phone: string) => {
const phoneNum = parsePhoneNumberFromString(code + phone);
return phoneNum?.isValid();
};
const handleBlur = () => {
setTouched(true);
if (!phoneNumber) {
setFormError(t('settingForm.thisFieldIsRequired'));
return;
}
if (!isPhoneValid(countryCode, phoneNumber)) {
setFormError(t('settingForm.phoneNumberIsInvalid'));
} else {
setFormError(undefined);
}
};
const toggleEdit = () => {
setIsEditing((prev) => {
if (!prev) {
setPhoneNumber('');
setVerificationCode('');
setIsVerified(false);
setButtonState('default');
setShowToast(false);
setFormError(undefined);
setTouched(false);
}
return !prev;
});
};
const handleSendCode = () => {
handleBlur();
if (formError || !isPhoneValid(countryCode, phoneNumber)) return;
executeSendCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
});
};
const handleVerifyCode = () => {
if (!verificationCode) {
setFormError(t('settingForm.verificationCodeRequired'));
return;
}
setFormError(undefined);
executeConfirmCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
verifyCode: verificationCode,
});
};
const combinedError = formError || getErrorMessage(apiError);
const inputError: boolean = touched && !!combinedError;
return (
<PageWrapper>
<CardContainer
title={t('settingForm.titlePhoneNumber')}
subtitle={t('settingForm.descriptionPhoneNumber')}
highlighted={isEditing}
action={
<PhoneActionButtons
isEditing={isEditing}
toggleEdit={toggleEdit}
t={t}
/>
}
>
{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">
{getErrorMessage(fetchError) ||
t('settingForm.errorFetchPhoneNumber')}
</Typography>
</Box>
) : isEditing ? (
<PhoneEditForm
phoneNumber={phoneNumber}
setPhoneNumber={setPhoneNumber}
countryCode={countryCode}
setCountryCode={setCountryCode}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
isVerified={isVerified}
isVerifying={isVerifying || isSendingCode || isChangingPhone}
buttonState={buttonState}
setButtonState={setButtonState}
handleSendCode={handleSendCode}
handleVerifyClick={handleVerifyCode}
error={combinedError}
inputError={inputError}
handleBlur={handleBlur}
textFieldRef={textFieldRef as React.RefObject<HTMLDivElement>}
inputRef={inputRef as React.RefObject<HTMLInputElement>}
phones={phones}
showToast={showToast}
setShowToast={setShowToast}
t={t}
/>
) : (
<PhoneDisplay
phones={phones.map((p) => {
let localPhone = p.withCode;
if (localPhone.startsWith('+98')) {
localPhone = '0' + localPhone.slice(3);
}
return {
...p,
phone: toLocaleDigits(localPhone, i18n.language),
};
})}
/>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import SocialMediaList from './socialMedia/SocialMediaList';
import SocialMediaMenu from './socialMedia/SocialMediaMenu';
import SocialMediaDialog from './socialMedia/SocialMediaDialog';
import useMediaQuery from '@mui/material/useMediaQuery';
import type { Theme } from '@mui/material/styles';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useManualApi } from '@/hooks/useApi';
import {
fetchProfile,
sendEmailCode,
confirmEmailCode,
changeEmail,
} from '../../api/settingsApi';
import { type EmailAccount } from '../../types/settingsType';
export function SocialMedia() {
const { t } = useTranslation('setting');
const [openDialog, setOpenDialog] = useState(false);
const [emailInput, setEmailInput] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [dialogStep, setDialogStep] = useState<'enterEmail' | 'enterCode'>(
'enterEmail',
);
const [emailList, setEmailList] = useState<EmailAccount[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: sendCodeData,
loading: isSendingCode,
error: sendCodeError,
execute: executeSendCode,
} = useManualApi(sendEmailCode);
const {
data: confirmData,
loading: isConfirming,
error: confirmError,
execute: executeConfirmCode,
} = useManualApi(confirmEmailCode);
const {
data: changeEmailData,
loading: isChangingEmail,
error: changeEmailError,
execute: executeChangeEmail,
} = useManualApi(changeEmail);
const fullScreen = useMediaQuery((theme: Theme) =>
theme.breakpoints.down('sm'),
);
const downMd = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
const computedMaxWidth = (fullScreen ? 'xs' : downMd ? 'sm' : 'md') as
| 'xs'
| 'sm'
| 'md'
| 'lg'
| 'xl';
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success && profileData.email) {
const { email } = profileData;
setEmailList([
{
email,
provider: email.includes('@gmail.') ? 'google' : 'email',
time: '',
},
]);
}
}, [profileData]);
useEffect(() => {
if (sendCodeData?.success) {
setDialogStep('enterCode');
}
}, [sendCodeData]);
useEffect(() => {
if (confirmData?.success && confirmData.confirm) {
executeChangeEmail({ email: emailInput });
}
}, [confirmData, emailInput, executeChangeEmail]);
useEffect(() => {
if (changeEmailData?.success) {
setEmailList((prev) => [
...prev,
{
email: emailInput,
provider: emailInput.includes('@gmail.') ? 'google' : 'email',
time: t('settingForm.justNow'),
},
]);
resetDialog();
}
}, [changeEmailData, emailInput, t]);
const resetDialog = () => {
setOpenDialog(false);
setEmailInput('');
setVerificationCode('');
setFormError(null);
setDialogStep('enterEmail');
};
const handleSendCode = () => {
if (!/^\S+@\S+\.\S+$/.test(emailInput)) {
setFormError(t('settingForm.emailIsInvalid'));
return;
}
setFormError(null);
executeSendCode({ email: emailInput });
};
const handleConfirmAndChangeEmail = () => {
if (verificationCode.length < 4) {
setFormError(t('settingForm.verificationCodeRequired'));
return;
}
setFormError(null);
executeConfirmCode({ email: emailInput, verifyCode: verificationCode });
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const apiError = useMemo(
() => getErrorMessage(sendCodeError || confirmError || changeEmailError),
[sendCodeError, confirmError, changeEmailError],
);
return (
<PageWrapper>
<CardContainer
title={t('settingForm.titleSocial')}
subtitle={t('settingForm.descriptionSocial')}
action={
<SocialMediaMenu t={t} onOpenDialog={() => setOpenDialog(true)} />
}
>
{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">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<SocialMediaList t={t} emailList={emailList} />
)}
<SocialMediaDialog
open={openDialog}
onClose={resetDialog}
t={t}
emailInput={emailInput}
setEmailInput={setEmailInput}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
apiError={formError || apiError}
isLoading={isSendingCode || isConfirming || isChangingEmail}
dialogStep={dialogStep}
onSendCode={handleSendCode}
onConfirmEmail={handleConfirmAndChangeEmail}
fullScreen={fullScreen}
computedMaxWidth={computedMaxWidth}
/>
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,23 @@
import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { type DisplayFieldProps } from '@/features/profile/types/settingsType';
export function DisplayField({ label, value }: DisplayFieldProps) {
const { t } = useTranslation('setting');
const displayValue = value?.trim() || t('settingForm.notDetermined');
return (
<Box
sx={{
flex: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="body1" color="text.primary">
{displayValue}
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,105 @@
import { Box, Typography, Avatar } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { DisplayField } from './DisplayField';
import { CountryFlag } from '@/components/CountryFlag';
import { Gender } from '@/features/profile/types/settingsType';
import { type InfoRowDisplayProps } from '@/features/profile/types/settingsType';
export function InfoRowDisplay({
data,
uploadedImageUrl,
initials,
}: InfoRowDisplayProps) {
const { t } = useTranslation('setting');
const displayValue = (value: string) =>
value?.trim() || t('settingForm.notDetermined');
const getGenderLabel = (gender: Gender | '') => {
switch (gender) {
case Gender.Male:
return t('settingForm.man');
case Gender.Female:
return t('settingForm.woman');
default:
return t('settingForm.notDetermined');
}
};
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ width: '100%' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{t('settingForm.name')} و {t('settingForm.familyName')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
src={uploadedImageUrl || undefined}
sx={{
width: 32,
height: 32,
bgcolor: 'secondary.main',
fontSize: '16px',
}}
>
{initials}
</Avatar>
<Typography variant="body1" color="text.primary">
{`${displayValue(data.firstName)} ${displayValue(
data.lastName,
)}`}
</Typography>
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settingForm.country')}
</Typography>
<Box sx={{ mt: 0.5 }}>
{data.country ? (
<CountryFlag code={data.country} />
) : (
<Typography variant="body1" color="text.primary">
{t('settingForm.notDetermined')}
</Typography>
)}
</Box>
</Box>
</Box>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
mt: 2,
}}
>
<DisplayField
label={t('settingForm.gender')}
value={getGenderLabel(data.gender)}
/>
<DisplayField
label={t('settingForm.nationalCode')}
value={displayValue(data.nationalCode)}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,118 @@
import {
Box,
TextField,
FormControl,
InputLabel,
MenuItem,
Select,
Autocomplete,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { countries } from '@/features/profile/data/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { Gender } from '@/features/profile/types/settingsType';
import { type InfoRowEditProps } from '@/features/profile/types/settingsType';
export function InfoRowEdit({ data, setData }: InfoRowEditProps) {
const { t } = useTranslation(['countries', 'setting']);
const countryOptions = countries.map((c) => ({
code: c.code,
label: t(c.label, { ns: 'countries' }),
}));
const currentCountry =
countryOptions.find((c) => c.code === data.country) || null;
const fields = [
{
name: 'firstName' as const,
label: t('settingForm.name', { ns: 'setting' }),
value: data.firstName,
},
{
name: 'lastName' as const,
label: t('settingForm.familyName', { ns: 'setting' }),
value: data.lastName,
},
{
name: 'nationalCode' as const,
label: t('settingForm.nationalCode', { ns: 'setting' }),
value: data.nationalCode,
},
];
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{fields.map(({ name, label, value }) => (
<Box
key={name}
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
>
<TextField
fullWidth
name={name}
value={value}
onChange={(e) =>
setData((prev) => ({
...prev,
[name]: e.target.value,
}))
}
label={label}
/>
</Box>
))}
<Box sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}>
<FormControl fullWidth>
<InputLabel>
{t('settingForm.genderPlaceholder', { ns: 'setting' })}
</InputLabel>
<Select
value={data.gender === Gender.None ? '' : data.gender}
label={t('settingForm.genderPlaceholder', { ns: 'setting' })}
onChange={(e) =>
setData((prev) => ({
...prev,
gender: e.target.value as Gender,
}))
}
>
<MenuItem value={Gender.Male}>
{t('settingForm.man', { ns: 'setting' })}
</MenuItem>
<MenuItem value={Gender.Female}>
{t('settingForm.woman', { ns: 'setting' })}
</MenuItem>
</Select>
</FormControl>
</Box>
<Autocomplete
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
options={countryOptions}
getOptionLabel={(option) => option.label}
value={currentCountry}
onChange={(_, newValue) =>
setData((prev) => ({
...prev,
country: newValue?.code || '',
}))
}
renderOption={(props, option) => (
<Box component="li" {...props} key={option.code}>
<CountryFlag code={option.code} />
{option.label}
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label={t('settingForm.country', { ns: 'setting' })}
/>
)}
clearOnEscape
/>
</Box>
);
}

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { Box, Avatar, Typography, Button, IconButton } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { Camera, Trash } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type ProfileImageProps } from '@/features/profile/types/settingsType';
const MAX_FILE_SIZE_MB = 10;
export function ProfileImage({
initials,
uploadedImageUrl,
onImageChange,
onRemoveImage,
}: ProfileImageProps) {
const { t } = useTranslation('setting');
const [error, setError] = useState<string | null>(null);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const file = e.target.files?.[0];
if (!file) return;
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > MAX_FILE_SIZE_MB) {
setError(t('settingForm.fileSizeError', { size: MAX_FILE_SIZE_MB }));
return;
}
onImageChange(file);
};
return (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}
>
<Avatar
sx={{
bgcolor: 'secondary.main',
width: 88,
height: 88,
fontSize: '20px',
}}
src={uploadedImageUrl || undefined}
>
{initials}
</Avatar>
<Box>
<Typography variant="body1">
{t('settingForm.profilePicture')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('settingForm.allowedFormat')}
</Typography>
{error && (
<Typography variant="body2" color="error" mt={1}>
{error}
</Typography>
)}
<Box mt={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant={
uploadedImageUrl && onRemoveImage ? 'outlined' : 'contained'
}
component="label"
size="small"
sx={{
borderRadius: 2,
textTransform: 'none',
}}
startIcon={
<Icon
Component={Camera}
size="small"
color={
uploadedImageUrl && onRemoveImage
? 'primary.main'
: 'background.paper'
}
/>
}
>
{uploadedImageUrl && onRemoveImage
? t('settingForm.changePicture')
: t('settingForm.uploadPicture')}
<input
hidden
accept="image/png, image/jpeg, image/gif"
type="file"
onChange={handleImageChange}
/>
</Button>
{uploadedImageUrl && onRemoveImage && (
<IconButton
size="small"
color="error"
onClick={onRemoveImage}
aria-label={t('settingForm.removePicture')}
>
<Icon Component={Trash} color="error.main" />
</IconButton>
)}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,43 @@
import { Box, Button } from '@mui/material';
import { type PhoneActionButtonsProps } from '@/features/profile/types/settingsType';
export default function PhoneActionButtons({
isEditing,
toggleEdit,
t,
}: PhoneActionButtonsProps) {
return (
<Box sx={{ display: 'flex', gap: 1 }}>
{isEditing && (
<Button
variant="text"
onClick={toggleEdit}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
}}
>
{t('settingForm.rejectButton')}
</Button>
)}
<Button
onClick={toggleEdit}
size="large"
variant="outlined"
sx={{
borderRadius: 1,
bgcolor: isEditing ? 'primary.main' : 'background.default',
color: isEditing ? 'primary.contrastText' : 'primary.main',
whiteSpace: 'nowrap',
textTransform: 'none',
}}
>
{isEditing
? t('settingForm.saveButton')
: t('settingForm.editPhoneNumber')}
</Button>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import { Box, Typography } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui';
import { Mobile } from 'iconsax-react';
import { type PhoneDisplayProps } from '@/features/profile/types/settingsType';
export default function PhoneDisplay({ phones }: PhoneDisplayProps) {
return (
<>
{phones.map((item, index) => (
<Box
key={index}
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
py: 2,
mx: 3,
gap: 1,
}}
>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: 'primary.light',
justifyContent: 'center',
alignItems: 'center',
display: 'flex',
borderRadius: 0.5,
}}
>
<Icon
Component={Mobile}
size="medium"
variant="Bold"
color="primary.main"
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" color="text.primary">
{item.phone}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.time}
</Typography>
</Box>
</Box>
))}
</>
);
}

View File

@@ -0,0 +1,196 @@
import {
Box,
Typography,
TextField,
Button,
IconButton,
InputAdornment,
CircularProgress,
} from '@mui/material';
import { Edit2, TickCircle } from 'iconsax-react';
import { CountDownTimer } from '@/components/CountDownTimer';
import { CountryCodeSelector } from '../../CountryCodeSelector';
import { Icon } from '@rkheftan/harmony-ui';
import { type PhoneEditFormProps } from '@/features/profile/types/settingsType';
export default function PhoneEditForm({
phoneNumber,
setPhoneNumber,
countryCode,
setCountryCode,
verificationCode,
setVerificationCode,
isVerified,
isVerifying,
buttonState,
setButtonState,
handleSendCode,
handleVerifyClick,
error,
inputError,
handleBlur,
textFieldRef,
inputRef,
phones,
t,
}: PhoneEditFormProps) {
const isValidPhoneNumber = (phone: string) => {
const digitsOnly = phone.replace(/\D/g, '');
return digitsOnly.length >= 8 && digitsOnly.length <= 15;
};
return (
<>
<Box sx={{ width: '100%' }}>
<Box sx={{ mb: 2, mx: 3 }}>
<Typography variant="h6">
{t('settingForm.editPhoneNumber')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('settingForm.phoneNumberText')}({phones.map((p) => p.withCode)})
{t('settingForm.verb')}
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
alignItems: 'center',
mx: 3,
}}
>
<TextField
name="phoneNumber"
label={t('settingForm.newPhoneNumber')}
type="tel"
value={phoneNumber}
ref={textFieldRef}
inputRef={inputRef}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
onChange={(e) => setPhoneNumber(e.target.value)}
sx={{ flex: '1 1 220px', minWidth: 0 }}
placeholder="09123456789"
InputProps={{
endAdornment:
buttonState === 'counting' ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => {
setButtonState('default');
setPhoneNumber('');
setVerificationCode('');
}}
edge="end"
>
<Icon
Component={Edit2}
color="primary.main"
variant="Bold"
/>
</IconButton>
</InputAdornment>
) : (
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
show={true}
menuAnchor={textFieldRef.current}
onCloseFocusRef={inputRef}
/>
),
}}
/>
{isVerified ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: 'success.main',
}}
>
<Icon Component={TickCircle} />
<Typography>{t('settingForm.successButton')}</Typography>
</Box>
) : (
<Button
variant="text"
onClick={() => {
if (isValidPhoneNumber(phoneNumber)) {
handleSendCode();
}
}}
disabled={
buttonState === 'counting' ||
phoneNumber.length === 0 ||
!isValidPhoneNumber(phoneNumber)
}
sx={{
minWidth: { xs: '100%', sm: 220 },
color: 'primary.main',
textTransform: 'none',
}}
>
{buttonState === 'counting' ? (
<CountDownTimer
initialSeconds={60}
onComplete={() => setButtonState('default')}
/>
) : (
t('settingForm.verificationCodeButton')
)}
</Button>
)}
</Box>
{buttonState === 'counting' && !isVerified && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
mt: 2,
alignItems: 'center',
mx: 3,
}}
>
<TextField
name="verificationCode"
label={t('settingForm.verificationCode')}
type="tel"
value={verificationCode}
onChange={(e) =>
setVerificationCode(e.target.value.replace(/\D/g, ''))
}
sx={{ flex: '1 1 240px', minWidth: 0 }}
placeholder={t('settingForm.verificationCode')}
/>
<Button
variant="contained"
onClick={handleVerifyClick}
disabled={isVerifying || verificationCode.length === 0}
sx={{
minWidth: { xs: '100%', sm: 220 },
bgcolor: 'primary.main',
textTransform: 'none',
}}
>
{isVerifying ? (
<CircularProgress size={20} />
) : (
t('settingForm.checkCode')
)}
</Button>
</Box>
)}
</>
);
}

View File

@@ -0,0 +1,148 @@
import React, { type ReactElement, type ElementType } from 'react';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
} from '@mui/material';
import Slide from '@mui/material/Slide';
import type { TransitionProps } from '@mui/material/transitions';
import { CloseCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaDialogProps } from '@/features/profile/types/settingsType';
const MobileSlide = React.forwardRef(function MobileSlide(
props: TransitionProps & { children: ReactElement<unknown, ElementType> },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export default function SocialMediaDialog({
open,
onClose,
t,
emailInput,
setEmailInput,
fullScreen,
computedMaxWidth,
verificationCode,
setVerificationCode,
apiError,
isLoading,
dialogStep,
onSendCode,
onConfirmEmail,
}: SocialMediaDialogProps) {
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
fullScreen={fullScreen}
maxWidth={computedMaxWidth}
scroll="paper"
keepMounted
TransitionComponent={fullScreen ? MobileSlide : undefined}
PaperProps={{
sx: {
borderRadius: { xs: 0, sm: 2 },
width: { xs: '100%', sm: '30%' },
height: 'auto',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
fontSize: '16px',
gap: 1,
p: { xs: 1.5, sm: 2 },
bgcolor: 'background.default',
}}
>
<IconButton onClick={onClose} aria-label="Close" disabled={isLoading}>
<Icon Component={CloseCircle} size="medium" color="primary.main" />
</IconButton>
{t('settingForm.addEmailButton')}
</DialogTitle>
<DialogContent
dividers
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
py: { xs: 1.5, sm: 2 },
}}
>
<Box>
<Typography fontWeight="bold">{t('settingForm.newEmail')}</Typography>
<Typography variant="body2" color="text.secondary">
{t('settingForm.dialogHeader')}
</Typography>
</Box>
<TextField
fullWidth
type="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
label={t('settingForm.email')}
placeholder="abc@email.com"
autoComplete="email"
inputMode="email"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
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
variant="contained"
fullWidth
sx={{ textTransform: 'none', borderRadius: 2, mt: 1 }}
disabled={isLoading || (dialogStep === 'enterEmail' && !emailInput)}
onClick={dialogStep === 'enterEmail' ? onSendCode : onConfirmEmail}
>
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : dialogStep === 'enterEmail' ? (
t('settingForm.verificationCodeButton')
) : (
t('settingForm.confirmAndSave')
)}
</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
import { Box, Typography, IconButton } from '@mui/material';
import { Google, Sms, Trash } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaListProps } from '@/features/profile/types/settingsType';
export default function SocialMediaList({ emailList }: SocialMediaListProps) {
return (
<Box sx={{ width: '100%', borderRadius: 1, p: { xs: 1, sm: 2 } }}>
{emailList.map((item, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
py: 1,
gap: 1,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
minWidth: 0,
}}
>
<Box
sx={{
backgroundColor: 'primary.light',
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
borderRadius: 0.5,
}}
>
{item.provider === 'google' && (
<Icon
Component={Google}
size="medium"
color="primary.main"
variant="Bold"
/>
)}
{item.provider === 'email' && (
<Icon
Component={Sms}
size="medium"
variant="Bold"
color="primary.main"
/>
)}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" noWrap>
{item.email}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.time}
</Typography>
</Box>
</Box>
<IconButton aria-label="Delete">
<Icon Component={Trash} size="medium" variant="Outline" />
</IconButton>
</Box>
))}
</Box>
);
}

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import {
Box,
Button,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
} from '@mui/material';
import { Message, Google, Apple, ArrowDown3 } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaMenuProps } from '@/features/profile/types/settingsType';
export default function SocialMediaMenu({
t,
onOpenDialog,
}: SocialMediaMenuProps) {
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
const openMenu = Boolean(anchor);
const [open, setOpen] = useState(false);
const handleClickMenu = (e: React.MouseEvent<HTMLButtonElement>) => {
setOpen(true);
setAnchor(e.currentTarget);
};
const handleCloseMenu = () => {
setOpen(false);
setAnchor(null);
};
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-start',
gap: 1,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'stretch', sm: 'flex-start' },
gap: 1,
}}
>
<Button
onClick={handleClickMenu}
variant="outlined"
sx={{
border: '1px solid',
borderRadius: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
whiteSpace: 'nowrap',
backgroundColor: open ? 'primary.light' : 'primary.default',
textTransform: 'none',
}}
>
<Box component="span">{t('settingForm.addEmailOrSocialButton')}</Box>
<Icon
Component={ArrowDown3}
size="medium"
color="primary.main"
variant={open ? 'Bold' : 'Outline'}
/>
</Button>
</Box>
<Menu
anchorEl={anchor}
open={openMenu}
onClose={handleCloseMenu}
PaperProps={{
sx: {
minWidth: 187,
maxWidth: '90vw',
},
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<MenuItem
onClick={() => {
handleCloseMenu();
onOpenDialog();
}}
>
<ListItemIcon>
<Icon Component={Message} size="medium" color="primary.main" />
</ListItemIcon>
<ListItemText>{t('settingForm.email')}</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<Icon Component={Google} size="medium" color="primary.main" />
</ListItemIcon>
<ListItemText>{t('settingForm.google')}</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<Icon Component={Apple} size="medium" color="primary.main" />
</ListItemIcon>
<ListItemText>{t('settingForm.apple')}</ListItemText>
</MenuItem>
</Menu>
</Box>
);
}

View File

@@ -0,0 +1,271 @@
export interface Country {
code: string;
label: string;
phone: string;
}
export interface Country {
code: string;
label: string;
phone: string;
}
export const countries: readonly Country[] = [
{ code: 'AF', label: 'country.afghanistan', phone: '+93' },
{ code: 'AX', label: 'country.aland_islands', phone: '+358' },
{ code: 'AL', label: 'country.albania', phone: '+355' },
{ code: 'DZ', label: 'country.algeria', phone: '+213' },
{ code: 'AS', label: 'country.american_samoa', phone: '+1684' },
{ code: 'AD', label: 'country.andorra', phone: '+376' },
{ code: 'AO', label: 'country.angola', phone: '+244' },
{ code: 'AI', label: 'country.anguilla', phone: '+1264' },
{ code: 'AQ', label: 'country.antarctica', phone: '+672' },
{ code: 'AG', label: 'country.antigua_and_barbuda', phone: '+1268' },
{ code: 'AR', label: 'country.argentina', phone: '+54' },
{ code: 'AM', label: 'country.armenia', phone: '+374' },
{ code: 'AW', label: 'country.aruba', phone: '+297' },
{ code: 'AU', label: 'country.australia', phone: '+61' },
{ code: 'AT', label: 'country.austria', phone: '+43' },
{ code: 'AZ', label: 'country.azerbaijan', phone: '+994' },
{ code: 'BS', label: 'country.bahamas', phone: '+1242' },
{ code: 'BH', label: 'country.bahrain', phone: '+973' },
{ code: 'BD', label: 'country.bangladesh', phone: '+880' },
{ code: 'BB', label: 'country.barbados', phone: '+1246' },
{ code: 'BY', label: 'country.belarus', phone: '+375' },
{ code: 'BE', label: 'country.belgium', phone: '+32' },
{ code: 'BZ', label: 'country.belize', phone: '+501' },
{ code: 'BJ', label: 'country.benin', phone: '+229' },
{ code: 'BM', label: 'country.bermuda', phone: '+1441' },
{ code: 'BT', label: 'country.bhutan', phone: '+975' },
{ code: 'BO', label: 'country.bolivia', phone: '+591' },
{ code: 'BA', label: 'country.bosnia_and_herzegovina', phone: '+387' },
{ code: 'BW', label: 'country.botswana', phone: '+267' },
{ code: 'BR', label: 'country.brazil', phone: '+55' },
{
code: 'IO',
label: 'country.british_indian_ocean_territory',
phone: '+246',
},
{ code: 'VG', label: 'country.british_virgin_islands', phone: '+1284' },
{ code: 'BN', label: 'country.brunei', phone: '+673' },
{ code: 'BG', label: 'country.bulgaria', phone: '+359' },
{ code: 'BF', label: 'country.burkina_faso', phone: '+226' },
{ code: 'BI', label: 'country.burundi', phone: '+257' },
{ code: 'KH', label: 'country.cambodia', phone: '+855' },
{ code: 'CM', label: 'country.cameroon', phone: '+237' },
{ code: 'CA', label: 'country.canada', phone: '+1' },
{ code: 'CV', label: 'country.cape_verde', phone: '+238' },
{ code: 'KY', label: 'country.cayman_islands', phone: '+1345' },
{ code: 'CF', label: 'country.central_african_republic', phone: '+236' },
{ code: 'TD', label: 'country.chad', phone: '+235' },
{ code: 'CL', label: 'country.chile', phone: '+56' },
{ code: 'CN', label: 'country.china', phone: '+86' },
{ code: 'CX', label: 'country.christmas_island', phone: '+61' },
{ code: 'CC', label: 'country.cocos_keeling_islands', phone: '+61' },
{ code: 'CO', label: 'country.colombia', phone: '+57' },
{ code: 'KM', label: 'country.comoros', phone: '+269' },
{ code: 'CG', label: 'country.congo_brazzaville', phone: '+242' },
{ code: 'CD', label: 'country.congo_kinshasa', phone: '+243' },
{ code: 'CK', label: 'country.cook_islands', phone: '+682' },
{ code: 'CR', label: 'country.costa_rica', phone: '+506' },
{ code: 'CI', label: 'country.cote_divoire', phone: '+225' },
{ code: 'HR', label: 'country.croatia', phone: '+385' },
{ code: 'CU', label: 'country.cuba', phone: '+53' },
{ code: 'CW', label: 'country.curacao', phone: '+599' },
{ code: 'CY', label: 'country.cyprus', phone: '+357' },
{ code: 'CZ', label: 'country.czech_republic', phone: '+420' },
{ code: 'DK', label: 'country.denmark', phone: '+45' },
{ code: 'DJ', label: 'country.djibouti', phone: '+253' },
{ code: 'DM', label: 'country.dominica', phone: '+1767' },
{ code: 'DO', label: 'country.dominican_republic', phone: '+1' },
{ code: 'EC', label: 'country.ecuador', phone: '+593' },
{ code: 'EG', label: 'country.egypt', phone: '+20' },
{ code: 'SV', label: 'country.el_salvador', phone: '+503' },
{ code: 'GQ', label: 'country.equatorial_guinea', phone: '+240' },
{ code: 'ER', label: 'country.eritrea', phone: '+291' },
{ code: 'EE', label: 'country.estonia', phone: '+372' },
{ code: 'SZ', label: 'country.eswatini', phone: '+268' },
{ code: 'ET', label: 'country.ethiopia', phone: '+251' },
{ code: 'FK', label: 'country.falkland_islands', phone: '+500' },
{ code: 'FO', label: 'country.faroe_islands', phone: '+298' },
{ code: 'FJ', label: 'country.fiji', phone: '+679' },
{ code: 'FI', label: 'country.finland', phone: '+358' },
{ code: 'FR', label: 'country.france', phone: '+33' },
{ code: 'GF', label: 'country.french_guiana', phone: '+594' },
{ code: 'PF', label: 'country.french_polynesia', phone: '+689' },
{ code: 'GA', label: 'country.gabon', phone: '+241' },
{ code: 'GM', label: 'country.gambia', phone: '+220' },
{ code: 'GE', label: 'country.georgia', phone: '+995' },
{ code: 'DE', label: 'country.germany', phone: '+49' },
{ code: 'GH', label: 'country.ghana', phone: '+233' },
{ code: 'GI', label: 'country.gibraltar', phone: '+350' },
{ code: 'GR', label: 'country.greece', phone: '+30' },
{ code: 'GL', label: 'country.greenland', phone: '+299' },
{ code: 'GD', label: 'country.grenada', phone: '+1473' },
{ code: 'GP', label: 'country.guadeloupe', phone: '+590' },
{ code: 'GU', label: 'country.guam', phone: '+1671' },
{ code: 'GT', label: 'country.guatemala', phone: '+502' },
{ code: 'GG', label: 'country.guernsey', phone: '+44' },
{ code: 'GN', label: 'country.guinea', phone: '+224' },
{ code: 'GW', label: 'country.guinea_bissau', phone: '+245' },
{ code: 'GY', label: 'country.guyana', phone: '+592' },
{ code: 'HT', label: 'country.haiti', phone: '+509' },
{ code: 'HN', label: 'country.honduras', phone: '+504' },
{ code: 'HK', label: 'country.hong_kong', phone: '+852' },
{ code: 'HU', label: 'country.hungary', phone: '+36' },
{ code: 'IS', label: 'country.iceland', phone: '+354' },
{ code: 'IN', label: 'country.india', phone: '+91' },
{ code: 'ID', label: 'country.indonesia', phone: '+62' },
{ code: 'IR', label: 'country.iran', phone: '+98' },
{ code: 'IQ', label: 'country.iraq', phone: '+964' },
{ code: 'IE', label: 'country.ireland', phone: '+353' },
{ code: 'IM', label: 'country.isle_of_man', phone: '+44' },
{ code: 'IL', label: 'country.israel', phone: '+972' },
{ code: 'IT', label: 'country.italy', phone: '+39' },
{ code: 'JM', label: 'country.jamaica', phone: '+1876' },
{ code: 'JP', label: 'country.japan', phone: '+81' },
{ code: 'JE', label: 'country.jersey', phone: '+44' },
{ code: 'JO', label: 'country.jordan', phone: '+962' },
{ code: 'KZ', label: 'country.kazakhstan', phone: '+7' },
{ code: 'KE', label: 'country.kenya', phone: '+254' },
{ code: 'KI', label: 'country.kiribati', phone: '+686' },
{ code: 'XK', label: 'country.kosovo', phone: '+383' },
{ code: 'KW', label: 'country.kuwait', phone: '+965' },
{ code: 'KG', label: 'country.kyrgyzstan', phone: '+996' },
{ code: 'LA', label: 'country.laos', phone: '+856' },
{ code: 'LV', label: 'country.latvia', phone: '+371' },
{ code: 'LB', label: 'country.lebanon', phone: '+961' },
{ code: 'LS', label: 'country.lesotho', phone: '+266' },
{ code: 'LR', label: 'country.liberia', phone: '+231' },
{ code: 'LY', label: 'country.libya', phone: '+218' },
{ code: 'LI', label: 'country.liechtenstein', phone: '+423' },
{ code: 'LT', label: 'country.lithuania', phone: '+370' },
{ code: 'LU', label: 'country.luxembourg', phone: '+352' },
{ code: 'MO', label: 'country.macau', phone: '+853' },
{ code: 'MG', label: 'country.madagascar', phone: '+261' },
{ code: 'MW', label: 'country.malawi', phone: '+265' },
{ code: 'MY', label: 'country.malaysia', phone: '+60' },
{ code: 'MV', label: 'country.maldives', phone: '+960' },
{ code: 'ML', label: 'country.mali', phone: '+223' },
{ code: 'MT', label: 'country.malta', phone: '+356' },
{ code: 'MH', label: 'country.marshall_islands', phone: '+692' },
{ code: 'MQ', label: 'country.martinique', phone: '+596' },
{ code: 'MR', label: 'country.mauritania', phone: '+222' },
{ code: 'MU', label: 'country.mauritius', phone: '+230' },
{ code: 'YT', label: 'country.mayotte', phone: '+262' },
{ code: 'MX', label: 'country.mexico', phone: '+52' },
{ code: 'FM', label: 'country.micronesia', phone: '+691' },
{ code: 'MD', label: 'country.moldova', phone: '+373' },
{ code: 'MC', label: 'country.monaco', phone: '+377' },
{ code: 'MN', label: 'country.mongolia', phone: '+976' },
{ code: 'ME', label: 'country.montenegro', phone: '+382' },
{ code: 'MS', label: 'country.montserrat', phone: '+1664' },
{ code: 'MA', label: 'country.morocco', phone: '+212' },
{ code: 'MZ', label: 'country.mozambique', phone: '+258' },
{ code: 'MM', label: 'country.myanmar', phone: '+95' },
{ code: 'NA', label: 'country.namibia', phone: '+264' },
{ code: 'NR', label: 'country.nauru', phone: '+674' },
{ code: 'NP', label: 'country.nepal', phone: '+977' },
{ code: 'NL', label: 'country.netherlands', phone: '+31' },
{ code: 'NC', label: 'country.new_caledonia', phone: '+687' },
{ code: 'NZ', label: 'country.new_zealand', phone: '+64' },
{ code: 'NI', label: 'country.nicaragua', phone: '+505' },
{ code: 'NE', label: 'country.niger', phone: '+227' },
{ code: 'NG', label: 'country.nigeria', phone: '+234' },
{ code: 'NU', label: 'country.niue', phone: '+683' },
{ code: 'NF', label: 'country.norfolk_island', phone: '+672' },
{ code: 'KP', label: 'country.north_korea', phone: '+850' },
{ code: 'MK', label: 'country.north_macedonia', phone: '+389' },
{ code: 'MP', label: 'country.northern_mariana_islands', phone: '+1670' },
{ code: 'NO', label: 'country.norway', phone: '+47' },
{ code: 'OM', label: 'country.oman', phone: '+968' },
{ code: 'PK', label: 'country.pakistan', phone: '+92' },
{ code: 'PW', label: 'country.palau', phone: '+680' },
{ code: 'PS', label: 'country.palestine', phone: '+970' },
{ code: 'PA', label: 'country.panama', phone: '+507' },
{ code: 'PG', label: 'country.papua_new_guinea', phone: '+675' },
{ code: 'PY', label: 'country.paraguay', phone: '+595' },
{ code: 'PE', label: 'country.peru', phone: '+51' },
{ code: 'PH', label: 'country.philippines', phone: '+63' },
{ code: 'PN', label: 'country.pitcairn_islands', phone: '+64' },
{ code: 'PL', label: 'country.poland', phone: '+48' },
{ code: 'PT', label: 'country.portugal', phone: '+351' },
{ code: 'PR', label: 'country.puerto_rico', phone: '+1' },
{ code: 'QA', label: 'country.qatar', phone: '+974' },
{ code: 'RE', label: 'country.reunion', phone: '+262' },
{ code: 'RO', label: 'country.romania', phone: '+40' },
{ code: 'RU', label: 'country.russia', phone: '+7' },
{ code: 'RW', label: 'country.rwanda', phone: '+250' },
{ code: 'BL', label: 'country.saint_barthelemy', phone: '+590' },
{ code: 'SH', label: 'country.saint_helena', phone: '+290' },
{ code: 'KN', label: 'country.saint_kitts_and_nevis', phone: '+1869' },
{ code: 'LC', label: 'country.saint_lucia', phone: '+1758' },
{ code: 'MF', label: 'country.saint_martin', phone: '+590' },
{ code: 'PM', label: 'country.saint_pierre_and_miquelon', phone: '+508' },
{
code: 'VC',
label: 'country.saint_vincent_and_the_grenadines',
phone: '+1784',
},
{ code: 'WS', label: 'country.samoa', phone: '+685' },
{ code: 'SM', label: 'country.san_marino', phone: '+378' },
{ code: 'ST', label: 'country.sao_tome_and_principe', phone: '+239' },
{ code: 'SA', label: 'country.saudi_arabia', phone: '+966' },
{ code: 'SN', label: 'country.senegal', phone: '+221' },
{ code: 'RS', label: 'country.serbia', phone: '+381' },
{ code: 'SC', label: 'country.seychelles', phone: '+248' },
{ code: 'SL', label: 'country.sierra_leone', phone: '+232' },
{ code: 'SG', label: 'country.singapore', phone: '+65' },
{ code: 'SX', label: 'country.sint_maarten', phone: '+1721' },
{ code: 'SK', label: 'country.slovakia', phone: '+421' },
{ code: 'SI', label: 'country.slovenia', phone: '+386' },
{ code: 'SB', label: 'country.solomon_islands', phone: '+677' },
{ code: 'SO', label: 'country.somalia', phone: '+252' },
{ code: 'ZA', label: 'country.south_africa', phone: '+27' },
{
code: 'GS',
label: 'country.south_georgia_and_south_sandwich_islands',
phone: '+500',
},
{ code: 'KR', label: 'country.south_korea', phone: '+82' },
{ code: 'SS', label: 'country.south_sudan', phone: '+211' },
{ code: 'ES', label: 'country.spain', phone: '+34' },
{ code: 'LK', label: 'country.sri_lanka', phone: '+94' },
{ code: 'SD', label: 'country.sudan', phone: '+249' },
{ code: 'SR', label: 'country.suriname', phone: '+597' },
{ code: 'SJ', label: 'country.svalbard_and_jan_mayen', phone: '+47' },
{ code: 'SE', label: 'country.sweden', phone: '+46' },
{ code: 'CH', label: 'country.switzerland', phone: '+41' },
{ code: 'SY', label: 'country.syria', phone: '+963' },
{ code: 'TW', label: 'country.taiwan', phone: '+886' },
{ code: 'TJ', label: 'country.tajikistan', phone: '+992' },
{ code: 'TZ', label: 'country.tanzania', phone: '+255' },
{ code: 'TH', label: 'country.thailand', phone: '+66' },
{ code: 'TL', label: 'country.timor_leste', phone: '+670' },
{ code: 'TG', label: 'country.togo', phone: '+228' },
{ code: 'TK', label: 'country.tokelau', phone: '+690' },
{ code: 'TO', label: 'country.tonga', phone: '+676' },
{ code: 'TT', label: 'country.trinidad_and_tobago', phone: '+1868' },
{ code: 'TN', label: 'country.tunisia', phone: '+216' },
{ code: 'TR', label: 'country.turkey', phone: '+90' },
{ code: 'TM', label: 'country.turkmenistan', phone: '+993' },
{ code: 'TC', label: 'country.turks_and_caicos_islands', phone: '+1649' },
{ code: 'TV', label: 'country.tuvalu', phone: '+688' },
{ code: 'VI', label: 'country.us_virgin_islands', phone: '+1340' },
{ code: 'UG', label: 'country.uganda', phone: '+256' },
{ code: 'UA', label: 'country.ukraine', phone: '+380' },
{ code: 'AE', label: 'country.united_arab_emirates', phone: '+971' },
{ code: 'GB', label: 'country.united_kingdom', phone: '+44' },
{ code: 'US', label: 'country.united_states', phone: '+1' },
{ code: 'UY', label: 'country.uruguay', phone: '+598' },
{ code: 'UZ', label: 'country.uzbekistan', phone: '+998' },
{ code: 'VU', label: 'country.vanuatu', phone: '+678' },
{ code: 'VA', label: 'country.vatican_city', phone: '+39' },
{ code: 'VE', label: 'country.venezuela', phone: '+58' },
{ code: 'VN', label: 'country.vietnam', phone: '+84' },
{ code: 'WF', label: 'country.wallis_and_futuna', phone: '+681' },
{ code: 'EH', label: 'country.western_sahara', phone: '+212' },
{ code: 'YE', label: 'country.yemen', phone: '+967' },
{ code: 'ZM', label: 'country.zambia', phone: '+260' },
{ code: 'ZW', label: 'country.zimbabwe', phone: '+263' },
];

View File

@@ -0,0 +1,91 @@
import { Gender } from './settingsType';
/* General API Types */
export interface UserSettingsFromApi {
theme: number;
calendarType: number;
language: number;
}
export interface ApiSession {
key: string;
created: string;
deviceOs: string;
deviceName: string;
ipAddress: string;
}
export interface ActiveSessionsData {
sessions: ApiSession[];
currentKey: string;
}
export interface LoginLog {
loginDateTime: string;
ipAddress: string;
deviceName: string;
deviceOs: string;
}
/* Profile API Types */
export interface GetProfileApiResponse {
success: boolean;
message?: string;
firstName?: string;
lastName?: string;
nationalCode?: string;
gender?: Gender;
countryCode?: string;
profileImageUrl?: string;
phoneNumber?: string;
userSettings?: UserSettingsFromApi;
activeSessions?: ActiveSessionsData;
loginLogs?: LoginLog[];
email?: string;
hasPassword?: boolean;
}
export interface SaveProfileApiResponse {
success: boolean;
message?: string;
phoneNumber?: string;
email?: string;
}
/* Settings API Types */
export interface SaveSettingsApiResponse {
success: boolean;
message?: string;
}
/* Session API Types */
export interface DeleteSessionsApiResponse {
success: boolean;
message?: string;
}
/* Password API Types */
export interface PasswordApiResponse {
success: boolean;
message?: string;
}
/* Email API Types */
export interface SendEmailCodeApiResponse {
success: boolean;
message?: string;
}
export interface ConfirmEmailCodeApiResponse {
success: boolean;
confirm?: boolean;
message?: string;
}
export interface ChangeEmailApiResponse {
success: boolean;
message?: string;
}
/* Phone Number API Types */
export interface PhoneNumberApiResponse {
success: boolean;
message?: string;
}
export interface ConfirmPhoneNumberApiResponse extends PhoneNumberApiResponse {
confirm?: boolean;
}

View File

@@ -0,0 +1,146 @@
export enum Gender {
Male = 2,
Female = 1,
None = 0,
}
export interface InfoRowData {
firstName: string;
lastName: string;
nationalCode: string;
country: string | '';
gender: Gender;
}
export interface Device {
id: string;
timeAndDate: string;
deviceModel: string;
ip: string;
current: boolean;
}
export interface PasswordDialogProps {
open: boolean;
fullScreen: boolean;
handleClose: () => void;
password: string;
setPassword: (val: string) => void;
confirmPassword: string;
setConfirmPassword: (val: string) => void;
currentPassword: string;
setCurrentPassword: (val: string) => void;
showValidation: boolean;
validPassword: boolean;
matchPassword: boolean;
loading: boolean;
handleSubmit: () => void;
changePassword: boolean;
apiError: string | null;
t: (key: string) => string;
}
export interface ValidationItemProps {
isValid: boolean;
label: string;
}
export interface SocialMediaDialogProps {
open: boolean;
onClose: () => void;
t: (key: string) => string;
emailInput: string;
setEmailInput: (val: string) => void;
fullScreen: boolean;
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 interface SocialMediaListProps {
t: (key: string) => string;
emailList: readonly {
email: string;
provider: 'email' | 'google' | 'apple';
time: string;
}[];
}
export interface SocialMediaMenuProps {
t: (key: string) => string;
onOpenDialog: () => void;
}
export interface Phone {
phone: string;
time: string;
withCode: string;
}
export interface EmailAccount {
email: string;
provider: 'email' | 'google';
time: string;
}
export interface PhoneActionButtonsProps {
isEditing: boolean;
toggleEdit: () => void;
t: (key: string) => string;
}
export interface PhoneDisplayProps {
phones: { phone: string; time: string }[];
}
export interface PhoneEditFormProps {
phoneNumber: string;
setPhoneNumber: (v: string) => void;
countryCode: string;
setCountryCode: (v: string) => void;
verificationCode: string;
setVerificationCode: (v: string) => void;
isVerified: boolean;
isVerifying: boolean;
buttonState: 'default' | 'counting';
setButtonState: (v: 'default' | 'counting') => void;
handleSendCode: () => void;
handleVerifyClick: () => void;
error?: string;
inputError: boolean;
handleBlur: () => void;
textFieldRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
phones: { phone: string; time: string; withCode: string }[];
showToast: boolean;
setShowToast: (v: boolean) => void;
t: (key: string) => string;
}
export interface DisplayFieldProps {
label: string;
value: string;
}
export interface InfoRowDisplayProps {
data: InfoRowData;
uploadedImageUrl: string | null;
initials: string;
}
export interface InfoRowEditProps {
data: InfoRowData;
setData: React.Dispatch<React.SetStateAction<InfoRowData>>;
}
export interface ProfileImageProps {
initials: string;
uploadedImageUrl: string | null;
onImageChange: (file: File) => void;
onRemoveImage?: () => void;
}

View File

@@ -1,17 +1,17 @@
import type { ApiResponse } from '@/types/apiResponse';
// FIXME: all the responses should extend this interface
// import type { ApiResponse } from '@/types/apiResponse';
import { useToast } from '@rkheftan/harmony-ui';
import type { AxiosError } from 'axios';
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
// Define options for the hook
interface UseApiOptions {
// If true, the API call will be executed immediately on mount
immediate?: boolean;
}
export function useApi<T extends ApiResponse, P extends any[]>(
export function useApi<T, P extends any[]>(
apiFunction: (...args: P) => Promise<{ data: T }>,
options: UseApiOptions = {},
) {
@@ -51,7 +51,8 @@ export function useApi<T extends ApiResponse, P extends any[]>(
}
},
[apiFunction, navigate, t, toast],
);
)
// If the 'immediate' option is true, execute the function on mount
useEffect(() => {

View File

@@ -25,23 +25,36 @@ export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({
cssVariables: {
colorSchemeSelector: 'class',
},
spacing: 8,
typography: typography,
components: {
MuiButton: {
defaultProps: {
size: 'large',
fullWidth: true,
variant: 'contained',
sx: {
textTransform: 'none',
},
},
},
MuiTextField: {
defaultProps: {
variant: 'outlined',
fullWidth: true,
},
},
MuiButton: {
defaultProps: {
size: 'large',
fullWidth: true,
variant: 'contained',
MuiSelect: {
styleOverrides: {
root: {
textAlign: 'left',
},
select: {
textAlign: 'left',
},
},
},
},
spacing: 8,
typography: typography,
});
}, [i18n]);

View File

@@ -0,0 +1,38 @@
import { type TFunction } from 'i18next';
import { toLocaleDigits } from './persianDigit';
export function formatDate(
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 toLocaleDigits(
t('active.minutesAgo', { count: diffInMinutes }),
lang,
);
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
calendar: lang.startsWith('fa') ? 'persian' : 'gregory',
numberingSystem: lang.startsWith('fa') ? 'arab' : 'latn',
};
const displayLocale = lang.startsWith('fa') ? 'fa-IR' : 'en-US';
const formattedDate = new Intl.DateTimeFormat(displayLocale, options).format(
date,
);
return toLocaleDigits(formattedDate, lang);
}

View 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
View 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,
};
}

View File

@@ -6,6 +6,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"skipLibCheck": true,
/* Bundler & Module Resolution */
"moduleResolution": "bundler",