Merge branch 'develop' into feat/auth
This commit is contained in:
2
.npmrc
Normal file
2
.npmrc
Normal 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
1206
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -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",
|
||||
|
||||
38
public/locales/en/completionForm.json
Normal file
38
public/locales/en/completionForm.json
Normal 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."
|
||||
}
|
||||
}
|
||||
247
public/locales/en/country.json
Normal file
247
public/locales/en/country.json
Normal 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"
|
||||
}
|
||||
128
public/locales/en/setting.json
Normal file
128
public/locales/en/setting.json
Normal 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?"
|
||||
}
|
||||
}
|
||||
14
public/locales/en/sideMap.json
Normal file
14
public/locales/en/sideMap.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
public/locales/fa/completionForm.json
Normal file
46
public/locales/fa/completionForm.json
Normal file
File diff suppressed because one or more lines are too long
182
public/locales/fa/country.json
Normal file
182
public/locales/fa/country.json
Normal 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": "زیمبابوه"
|
||||
}
|
||||
128
public/locales/fa/setting.json
Normal file
128
public/locales/fa/setting.json
Normal 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": "رمز عبور را فراموش کرده اید؟"
|
||||
}
|
||||
}
|
||||
14
public/locales/fa/sideMap.json
Normal file
14
public/locales/fa/sideMap.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"side": {
|
||||
"account": "حساب کاربری",
|
||||
"personalInfo": "اطلاعات شخصی",
|
||||
"phoneNumber": "شماره تماس",
|
||||
"email": "ایمیل",
|
||||
"security": "امنیت",
|
||||
"password": "رمز عبور",
|
||||
"verifiedAddress": "آدرس های تایید شده",
|
||||
"recentLogins": "ورود های اخیر",
|
||||
"activeDevices": "دستگاه های فعال",
|
||||
"settings": "تنظیمات"
|
||||
}
|
||||
}
|
||||
63
src/components/CardContainer.tsx
Normal file
63
src/components/CardContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/CountDownTimer.tsx
Normal file
41
src/components/CountDownTimer.tsx
Normal 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>;
|
||||
}
|
||||
40
src/components/CountryFlag.tsx
Normal file
40
src/components/CountryFlag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/ThemToggle.tsx
Normal file
85
src/components/ThemToggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/features/authentication/api/userCompletion.ts
Normal file
32
src/features/authentication/api/userCompletion.ts
Normal 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;
|
||||
};
|
||||
72
src/features/authentication/components/DateOfBirth.tsx
Normal file
72
src/features/authentication/components/DateOfBirth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
src/features/authentication/components/EmailSection.tsx
Normal file
184
src/features/authentication/components/EmailSection.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
src/features/authentication/components/PasswordSection.tsx
Normal file
250
src/features/authentication/components/PasswordSection.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
168
src/features/authentication/components/PersonalInfoFields.tsx
Normal file
168
src/features/authentication/components/PersonalInfoFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/features/authentication/components/SubmitSection.tsx
Normal file
89
src/features/authentication/components/SubmitSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
329
src/features/authentication/components/UserCompletionForm.tsx
Normal file
329
src/features/authentication/components/UserCompletionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
src/features/authentication/data/Countries.ts
Normal file
271
src/features/authentication/data/Countries.ts
Normal 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' },
|
||||
];
|
||||
32
src/features/authentication/types/completionFormApiTypes.ts
Normal file
32
src/features/authentication/types/completionFormApiTypes.ts
Normal 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;
|
||||
}
|
||||
73
src/features/authentication/types/settingForm.ts
Normal file
73
src/features/authentication/types/settingForm.ts
Normal 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;
|
||||
}
|
||||
173
src/features/profile/api/settingsApi.ts
Normal file
173
src/features/profile/api/settingsApi.ts
Normal 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 };
|
||||
}
|
||||
254
src/features/profile/components/CountryCodeSelector.tsx
Normal file
254
src/features/profile/components/CountryCodeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/features/profile/components/PageWrapper.tsx
Normal file
21
src/features/profile/components/PageWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
src/features/profile/components/activeDevices/ActiveDevices.tsx
Normal file
285
src/features/profile/components/activeDevices/ActiveDevices.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
src/features/profile/components/security/PasswordDialog.tsx
Normal file
144
src/features/profile/components/security/PasswordDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/features/profile/components/security/PasswordSecurity.tsx
Normal file
191
src/features/profile/components/security/PasswordSecurity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
105
src/features/profile/components/security/RecentLogins.tsx
Normal file
105
src/features/profile/components/security/RecentLogins.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
src/features/profile/components/setting/Setting.tsx
Normal file
316
src/features/profile/components/setting/Setting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
256
src/features/profile/components/userInformation/PhoneNumber.tsx
Normal file
256
src/features/profile/components/userInformation/PhoneNumber.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/features/profile/components/userInformation/SocialMedia.tsx
Normal file
200
src/features/profile/components/userInformation/SocialMedia.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
271
src/features/profile/data/countries.ts
Normal file
271
src/features/profile/data/countries.ts
Normal 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' },
|
||||
];
|
||||
91
src/features/profile/types/settingsApiType.ts
Normal file
91
src/features/profile/types/settingsApiType.ts
Normal 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;
|
||||
}
|
||||
146
src/features/profile/types/settingsType.ts
Normal file
146
src/features/profile/types/settingsType.ts
Normal 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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
38
src/utils/formatSessionDate.tsx
Normal file
38
src/utils/formatSessionDate.tsx
Normal 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);
|
||||
}
|
||||
12
src/utils/persianDigit.tsx
Normal file
12
src/utils/persianDigit.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export const toLocaleDigits = (
|
||||
input: string | number,
|
||||
lang: string,
|
||||
): string => {
|
||||
const str = String(input);
|
||||
|
||||
if (lang.startsWith('fa')) {
|
||||
return str.replace(/\d/g, (d: string) => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d, 10)]);
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
15
src/utils/regex.ts
Normal file
15
src/utils/regex.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function regex(password: string) {
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasMinLength = password.length >= 8;
|
||||
const hasUpperAndLower = /[A-Z]/.test(password) && /[a-z]/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*]/.test(password);
|
||||
|
||||
return {
|
||||
hasNumber,
|
||||
hasMinLength,
|
||||
hasUpperAndLower,
|
||||
hasSpecialChar,
|
||||
validPassword:
|
||||
hasNumber && hasMinLength && hasUpperAndLower && hasSpecialChar,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler & Module Resolution */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user