diff --git a/.env b/.env new file mode 100644 index 0000000..2731127 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_GOOGLE_CLIENT_ID=https://272098283932-bft2gvlgjn8edopg0lnqjq1i9ekdmipt.apps.googleusercontent.com/ \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7c90784..cc58a0d 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -4,18 +4,18 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript trigger: -- develop + - develop pool: vmImage: ubuntu-latest steps: -- task: NodeTool@0 - inputs: - versionSpec: '20.x' - displayName: 'Install Node.js' + - task: NodeTool@0 + inputs: + versionSpec: '20.x' + displayName: 'Install Node.js' -- script: | - npm install - npm run build - displayName: 'npm install and build' + - script: | + npm install + npm run build + displayName: 'npm install and build' diff --git a/index.html b/index.html index 6e5d786..6255f17 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@ })(); +
diff --git a/package-lock.json b/package-lock.json index 9252efe..1ab4b3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,13 +20,18 @@ "date-fns": "^4.1.0", "date-fns-jalali": "^4.0.0-0", "dayjs": "^1.11.13", + "@rkheftan/harmony-ui": "^0.1.6", + "@types/stylis": "^4.2.7", "i18next": "^25.3.0", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "iconsax-react": "^0.0.8", + "libphonenumber-js": "^1.12.10", "react": "^19.1.0", + "react-country-flag": "^3.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.6.0", + "react-router-dom": "^7.8.0", "stylis": "^4.3.6", "stylis-plugin-rtl": "^2.1.1" }, @@ -35,6 +40,7 @@ "@types/node": "^24.0.10", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@types/stylis": "^4.2.7", "@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/parser": "^8.35.1", "@vitejs/plugin-react": "^4.5.2", @@ -2402,6 +2408,13 @@ "@types/react": "*" } }, + "node_modules/@types/stylis": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz", + "integrity": "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.39.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", @@ -2686,6 +2699,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3222,18 +3236,10 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT", - "peer": true - }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -3802,6 +3808,36 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4491,27 +4527,6 @@ "react": "*" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -4705,7 +4720,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema-typed": { "version": "8.0.1", @@ -4849,6 +4865,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.10", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz", + "integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==", + "license": "MIT" "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -5497,6 +5518,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-country-flag": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-country-flag/-/react-country-flag-3.1.0.tgz", + "integrity": "sha512-JWQFw1efdv9sTC+TGQvTKXQg1NKbDU2mBiAiRWcKM9F1sK+/zjhP2yGmm8YDddWyZdXVkR8Md47rPMJmo4YO5g==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/react-dom": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", @@ -5556,7 +5589,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz", "integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==", "license": "MIT", - "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -5579,7 +5611,6 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.0.tgz", "integrity": "sha512-ntInsnDVnVRdtSu6ODmTQ41cbluak/ENeTif7GBce0L6eztFg6/e1hXAysFQI8X25C8ipKmT9cClbJwxx3Kaqw==", "license": "MIT", - "peer": true, "dependencies": { "react-router": "7.8.0" }, @@ -5834,8 +5865,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/setimmediate": { "version": "1.0.5", diff --git a/package.json b/package.json index f38a567..732bb9f 100644 --- a/package.json +++ b/package.json @@ -18,18 +18,22 @@ "@mui/stylis-plugin-rtl": "^7.2.0", "@mui/x-date-pickers": "^8.9.0", "@mui/x-date-pickers-pro": "^8.9.0", - "@rkheftan/harmony-ui": "^0.1.5", "axios": "^1.11.0", "date-fns": "^4.1.0", "date-fns-jalali": "^4.0.0-0", "dayjs": "^1.11.13", + "@rkheftan/harmony-ui": "^0.1.6", + "@types/stylis": "^4.2.7", "i18next": "^25.3.0", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "iconsax-react": "^0.0.8", + "libphonenumber-js": "^1.12.10", "react": "^19.1.0", + "react-country-flag": "^3.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.6.0", + "react-router-dom": "^7.8.0", "stylis": "^4.3.6", "stylis-plugin-rtl": "^2.1.1" }, @@ -38,6 +42,7 @@ "@types/node": "^24.0.10", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@types/stylis": "^4.2.7", "@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/parser": "^8.35.1", "@vitejs/plugin-react": "^4.5.2", diff --git a/public/locales/en/authentication.json b/public/locales/en/authentication.json new file mode 100644 index 0000000..6633ea7 --- /dev/null +++ b/public/locales/en/authentication.json @@ -0,0 +1,53 @@ +{ + "loginForm": { + "title": "Login/Register", + "description": "Please enter your email/password to start", + "emailOrPhoneLabel": "Email/Password", + "submitButton": "Login/Register", + "loginWithGoogle": "Login with google", + "emailIsInvalid": "Email is invalid", + "phoneNumberIsInvalid": "Phone number is invalid", + "thisFieldIsRequired": "This field is requried" + }, + "verify": { + "verify": "Verify", + "a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt": "A 4-digit verification code has been sent to your mobile number. Please enter it.", + "thereIsNoAccountWithThisNumberA4DigitVerificationCodeHasBeenSentToThisNumberToCreateANewAccount": "There is no account with this number. A 4-digit verification code has been sent to this number to create a new account.", + "a4digitVerificationCodeHasBeenSentToYourEmailAddressPleaseEnterIt": "A 4-digit verification code has been sent to your email address. Please enter it.", + "thereIsNoAccountWithThisEmailAddressA4DigitVerificationCodeHasBeenSentToThisEmailAddressToCreateANewAccount": "There is no account with this email address. A 4-digit verification code has been sent to this email address to create a new account.", + "theVerificationCodeIsIncorrect": "The verification code is incorrect.", + "youHaveSuccessfullyLoggedIn": "You have successfully logged in", + "youHaveSuccessfullySignedIn": "You have successfully signed in", + "resendCodeIn": "Resend code in", + "moreMinute": "minute", + "resendCode": "Resend code" + }, + "completeSignUp": { + "completeSignUp": "Complete Sign Up", + "emailHasBeenSuccessfullyVerifiedPleaseEnterYourContactNumberToContinue": "Email {{ email }} has been successfully verified. Please enter your contact number to continue.", + "phoneNumber": "Phone number" + }, + "enterPassword": { + "loginWithPassword": "Login with password", + "enterThePasswordYouSetForYourAccount": "Enter the password you set for your account.", + "loginPassword": "Login password", + "loginWithOneTimeCode": "Login with one-time code", + "iForgotMyPassword": "I forgot my password." + }, + "forgetPassword": { + "forgetPassword": "Forget password", + "pleaseEnterYourMobileNumberEmailToRecoverYourPassword": "Please enter your mobile number/email to recover your password.", + "anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress": "An email containing a recovery code has been sent to this email address.", + "anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber": "An recovery code has been sent to this phone number.", + "confirm": "Confirm", + "changePassword": "Change password", + "createANewPassword": "Create a new password", + "newPassword": "New password", + "includingANumber": "Including a number", + "atLeast8Characters": "At least 8 characters", + "containsAnUppercaseAndLowercaseLetter": "Contains an uppercase and lowercase letter", + "ContainsASymbol": "Contains the symbol (!@#$%&*^)", + "confirmPassword": "Confirm password", + "passwordChangedSuccessfully": "Password changed successfully" + } +} diff --git a/public/locales/en/common.json b/public/locales/en/common.json index bc4b6ab..72b36d8 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1,3 +1,250 @@ { - "helloWorld": "hello world" + "helloWorld": "hello world", + "country": { + "afghanistan": "Afghanistan", + "aland_islands": "Aland islands", + "albania": "Albania", + "algeria": "Algeria", + "american_samoa": "American samoa", + "andorra": "Andorra", + "angola": "Angola", + "anguilla": "Anguilla", + "antarctica": "Antarctica", + "antigua_and_barbuda": "Antigua and barbuda", + "argentina": "Argentina", + "armenia": "Armenia", + "aruba": "Aruba", + "australia": "Australia", + "austria": "Austria", + "azerbaijan": "Azerbaijan", + "bahamas": "Bahamas", + "bahrain": "Bahrain", + "bangladesh": "Bangladesh", + "barbados": "Barbados", + "belarus": "Belarus", + "belgium": "Belgium", + "belize": "Belize", + "benin": "Benin", + "bermuda": "Bermuda", + "bhutan": "Bhutan", + "bolivia": "Bolivia", + "bosnia_and_herzegovina": "Bosnia and herzegovina", + "botswana": "Botswana", + "brazil": "Brazil", + "british_indian_ocean_territory": "British indian ocean territory", + "british_virgin_islands": "British virgin islands", + "brunei": "Brunei", + "bulgaria": "Bulgaria", + "burkina_faso": "Burkina faso", + "burundi": "Burundi", + "cambodia": "Cambodia", + "cameroon": "Cameroon", + "canada": "Canada", + "cape_verde": "Cape verde", + "cayman_islands": "Cayman islands", + "central_african_republic": "Central african republic", + "chad": "Chad", + "chile": "Chile", + "china": "China", + "christmas_island": "Christmas island", + "cocos_keeling_islands": "Cocos keeling islands", + "colombia": "Colombia", + "comoros": "Comoros", + "congo_brazzaville": "Congo brazzaville", + "congo_kinshasa": "Congo kinshasa", + "cook_islands": "Cook islands", + "costa_rica": "Costa rica", + "cote_divoire": "Cote divoire", + "croatia": "Croatia", + "cuba": "Cuba", + "curacao": "Curacao", + "cyprus": "Cyprus", + "czech_republic": "Czech republic", + "denmark": "Denmark", + "djibouti": "Djibouti", + "dominica": "Dominica", + "dominican_republic": "Dominican republic", + "ecuador": "Ecuador", + "egypt": "Egypt", + "el_salvador": "El salvador", + "equatorial_guinea": "Equatorial guinea", + "eritrea": "Eritrea", + "estonia": "Estonia", + "eswatini": "Eswatini", + "ethiopia": "Ethiopia", + "falkland_islands": "Falkland islands", + "faroe_islands": "Faroe islands", + "fiji": "Fiji", + "finland": "Finland", + "france": "France", + "french_guiana": "French guiana", + "french_polynesia": "French polynesia", + "gabon": "Gabon", + "gambia": "Gambia", + "georgia": "Georgia", + "germany": "Germany", + "ghana": "Ghana", + "gibraltar": "Gibraltar", + "greece": "Greece", + "greenland": "Greenland", + "grenada": "Grenada", + "guadeloupe": "Guadeloupe", + "guam": "Guam", + "guatemala": "Guatemala", + "guernsey": "Guernsey", + "guinea": "Guinea", + "guinea_bissau": "Guinea bissau", + "guyana": "Guyana", + "haiti": "Haiti", + "honduras": "Honduras", + "hong_kong": "Hong kong", + "hungary": "Hungary", + "iceland": "Iceland", + "india": "India", + "indonesia": "Indonesia", + "iran": "Iran", + "iraq": "Iraq", + "ireland": "Ireland", + "isle_of_man": "Isle of man", + "israel": "Israel", + "italy": "Italy", + "jamaica": "Jamaica", + "japan": "Japan", + "jersey": "Jersey", + "jordan": "Jordan", + "kazakhstan": "Kazakhstan", + "kenya": "Kenya", + "kiribati": "Kiribati", + "kosovo": "Kosovo", + "kuwait": "Kuwait", + "kyrgyzstan": "Kyrgyzstan", + "laos": "Laos", + "latvia": "Latvia", + "lebanon": "Lebanon", + "lesotho": "Lesotho", + "liberia": "Liberia", + "libya": "Libya", + "liechtenstein": "Liechtenstein", + "lithuania": "Lithuania", + "luxembourg": "Luxembourg", + "macau": "Macau", + "madagascar": "Madagascar", + "malawi": "Malawi", + "malaysia": "Malaysia", + "maldives": "Maldives", + "mali": "Mali", + "malta": "Malta", + "marshall_islands": "Marshall islands", + "martinique": "Martinique", + "mauritania": "Mauritania", + "mauritius": "Mauritius", + "mayotte": "Mayotte", + "mexico": "Mexico", + "micronesia": "Micronesia", + "moldova": "Moldova", + "monaco": "Monaco", + "mongolia": "Mongolia", + "montenegro": "Montenegro", + "montserrat": "Montserrat", + "morocco": "Morocco", + "mozambique": "Mozambique", + "myanmar": "Myanmar", + "namibia": "Namibia", + "nauru": "Nauru", + "nepal": "Nepal", + "netherlands": "Netherlands", + "new_caledonia": "New caledonia", + "new_zealand": "New zealand", + "nicaragua": "Nicaragua", + "niger": "Niger", + "nigeria": "Nigeria", + "niue": "Niue", + "norfolk_island": "Norfolk island", + "north_korea": "North korea", + "north_macedonia": "North macedonia", + "northern_mariana_islands": "Northern mariana islands", + "norway": "Norway", + "oman": "Oman", + "pakistan": "Pakistan", + "palau": "Palau", + "palestine": "Palestine", + "panama": "Panama", + "papua_new_guinea": "Papua new guinea", + "paraguay": "Paraguay", + "peru": "Peru", + "philippines": "Philippines", + "pitcairn_islands": "Pitcairn islands", + "poland": "Poland", + "portugal": "Portugal", + "puerto_rico": "Puerto rico", + "qatar": "Qatar", + "reunion": "Reunion", + "romania": "Romania", + "russia": "Russia", + "rwanda": "Rwanda", + "saint_barthelemy": "Saint barthelemy", + "saint_helena": "Saint helena", + "saint_kitts_and_nevis": "Saint kitts and nevis", + "saint_lucia": "Saint lucia", + "saint_martin": "Saint martin", + "saint_pierre_and_miquelon": "Saint pierre and miquelon", + "saint_vincent_and_the_grenadines": "Saint vincent and the grenadines", + "samoa": "Samoa", + "san_marino": "San marino", + "sao_tome_and_principe": "Sao tome and principe", + "saudi_arabia": "Saudi arabia", + "senegal": "Senegal", + "serbia": "Serbia", + "seychelles": "Seychelles", + "sierra_leone": "Sierra leone", + "singapore": "Singapore", + "sint_maarten": "Sint maarten", + "slovakia": "Slovakia", + "slovenia": "Slovenia", + "solomon_islands": "Solomon islands", + "somalia": "Somalia", + "south_africa": "South africa", + "south_georgia_and_south_sandwich_islands": "South georgia and south sandwich islands", + "south_korea": "South korea", + "south_sudan": "South sudan", + "spain": "Spain", + "sri_lanka": "Sri lanka", + "sudan": "Sudan", + "suriname": "Suriname", + "svalbard_and_jan_mayen": "Svalbard and jan mayen", + "sweden": "Sweden", + "switzerland": "Switzerland", + "syria": "Syria", + "taiwan": "Taiwan", + "tajikistan": "Tajikistan", + "tanzania": "Tanzania", + "thailand": "Thailand", + "timor_leste": "Timor leste", + "togo": "Togo", + "tokelau": "Tokelau", + "tonga": "Tonga", + "trinidad_and_tobago": "Trinidad and tobago", + "tunisia": "Tunisia", + "turkey": "Turkey", + "turkmenistan": "Turkmenistan", + "turks_and_caicos_islands": "Turks and caicos islands", + "tuvalu": "Tuvalu", + "us_virgin_islands": "Us virgin islands", + "uganda": "Uganda", + "ukraine": "Ukraine", + "united_arab_emirates": "United arab emirates", + "united_kingdom": "United kingdom", + "united_states": "United states", + "uruguay": "Uruguay", + "uzbekistan": "Uzbekistan", + "vanuatu": "Vanuatu", + "vatican_city": "Vatican city", + "venezuela": "Venezuela", + "vietnam": "Vietnam", + "wallis_and_futuna": "Wallis and futuna", + "western_sahara": "Western sahara", + "yemen": "Yemen", + "zambia": "Zambia", + "zimbabwe": "Zimbabwe" + } } diff --git a/public/locales/fa/authentication.json b/public/locales/fa/authentication.json new file mode 100644 index 0000000..bfb8610 --- /dev/null +++ b/public/locales/fa/authentication.json @@ -0,0 +1,55 @@ +{ + "loginForm": { + "title": "ورود/ثبت‌نام", + "description": "لطفا برای شروع شماره موبایل/ایمیل خود را وارد کنید.", + "emailOrPhoneLabel": "شماره موبایل/ایمیل", + "submitButton": "ورود/ثبت‌نام", + "loginWithGoogle": "ورود با گوگل", + "emailIsInvalid": "ایمیل وارد شده نامعتبر میباشد", + "phoneNumberIsInvalid": "شماره وارد شده نامعتبر میباشد", + "thisFieldIsRequired": "این فیلد الزامی است" + }, + "verify": { + "verify": "اعتبارسنجی", + "a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt": "کد تایید ۴ رقمی به شماره موبایل شما ارسال شد. لطفا آن را وارد کنید.", + "confirmAndLogin": "تایید و ورود", + "confirmAndContinue": "تایید و ادامه", + "thereIsNoAccountWithThisNumberA4DigitVerificationCodeHasBeenSentToThisNumberToCreateANewAccount": "حساب کاربری با این شماره وجود ندارد. برای ساخت حساب جدید، کد تایید ۴ رقمی برای این شماره ارسال گردید.", + "a4digitVerificationCodeHasBeenSentToYourEmailAddressPleaseEnterIt": "کد تایید ۴ رقمی به شماره ایمیل شما ارسال شد. لطفا آن را وارد کنید.", + "thereIsNoAccountWithThisEmailAddressA4DigitVerificationCodeHasBeenSentToThisEmailAddressToCreateANewAccount": "حساب کاربری با این ایمیل وجود ندارد. برای ساخت حساب جدید، کد تایید ۴ رقمی برای این ایمیل ارسال گردید.", + "theVerificationCodeIsIncorrect": "کد تایید اشتباه می باشد", + "youHaveSuccessfullyLoggedIn": "با موفقیت وارد شدید", + "youHaveSuccessfullySignedIn": "ثبت نام با موفقیت انجام شد", + "resendCodeIn": "ارسال مجدد کد تا", + "moreMinute": "دقیقه دیگر", + "resendCode": "ارسال مجدد" + }, + "completeSignUp": { + "completeSignUp": "تکمیل ثبت نام", + "emailHasBeenSuccessfullyVerifiedPleaseEnterYourContactNumberToContinue": "ایمیل {{ email }} با موفقیت تایید شد. برای ادامه لطفا شماره تماس خود را وارد کنید", + "phoneNumber": "شماره تماس" + }, + "enterPassword": { + "loginWithPassword": "ورود با رمز", + "enterThePasswordYouSetForYourAccount": "رمز ورودی که برای اکانت خود تعیین کردید را وارد کنید", + "loginPassword": "رمز ورود", + "loginWithOneTimeCode": "ورود با کد یکبار مصرف", + "iForgotMyPassword": "رمز ورودم را فراموش کردم" + }, + "forgetPassword": { + "forgetPassword": "فراموشی رمز", + "pleaseEnterYourMobileNumberEmailToRecoverYourPassword": "لطفا برای بازیابی رمز عبور شماره موبایل/ایمیل خود را وارد کنید.", + "anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress": "یک ایمیل حاوی کد بازیابی به این ایمیل ارسال شد", + "anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber": "یک کد بازیابی به این شماره ارسال شد", + "confirm": "تایید", + "changePassword": "تغییر رمز عبور", + "createANewPassword": "یک رمز عبور جدید ایجاد کنید", + "newPassword": "رمز عبور جدید", + "includingANumber": "شامل عدد", + "atLeast8Characters": "حداقل ۸ حرف", + "containsAnUppercaseAndLowercaseLetter": "شامل یک حرف بزرگ و کوچک", + "ContainsASymbol": "شامل علامت (!@#$%&*^)", + "confirmPassword": "تکرار رمز عبور", + "passwordChangedSuccessfully": "رمز عبور با موفقیت تغییر یافت" + } +} diff --git a/public/locales/fa/common.json b/public/locales/fa/common.json index 3f4cd0d..60424fb 100644 --- a/public/locales/fa/common.json +++ b/public/locales/fa/common.json @@ -1,3 +1,202 @@ { - "helloWorld": "سلام دنیا" + "labels": { + "search": "جست و جو" + }, + "country": { + "afghanistan": "افغانستان", + "aland_islands": "جزایر آلند", + "albania": "آلبانی", + "algeria": "الجزایر", + "american_samoa": "ساموای آمریکایی", + "andorra": "آندورا", + "angola": "آنگولا", + "anguilla": "آنگویلا", + "antarctica": "جنوبگان", + "antigua_and_barbuda": "آنتیگوا و باربودا", + "argentina": "آرژانتین", + "armenia": "ارمنستان", + "aruba": "آروبا", + "australia": "استرالیا", + "austria": "اتریش", + "azerbaijan": "آذربایجان", + "bahamas": "باهاما", + "bahrain": "بحرین", + "bangladesh": "بنگلادش", + "barbados": "باربادوس", + "belarus": "بلاروس", + "belgium": "بلژیک", + "belize": "بلیز", + "benin": "بنین", + "bermuda": "برمودا", + "bhutan": "بوتان", + "bolivia": "بولیوی", + "bosnia_and_herzegovina": "بوسنی و هرزگوین", + "botswana": "بوتسوانا", + "brazil": "برزیل", + "british_virgin_islands": "جزایر ویرجین بریتانیا", + "brunei": "برونئی", + "bulgaria": "بلغارستان", + "burkina_faso": "بورکینافاسو", + "burundi": "بوروندی", + "cambodia": "کامبوج", + "cameroon": "کامرون", + "canada": "کانادا", + "cape_verde": "کیپ ورد", + "cayman_islands": "جزایر کیمن", + "central_african_republic": "جمهوری آفریقای مرکزی", + "chad": "چاد", + "chile": "شیلی", + "china": "چین", + "colombia": "کلمبیا", + "comoros": "کومور", + "costa_rica": "کاستاریکا", + "cote_divoire": "ساحل عاج", + "croatia": "کرواسی", + "cuba": "کوبا", + "cyprus": "قبرس", + "czech_republic": "جمهوری چک", + "denmark": "دانمارک", + "djibouti": "جیبوتی", + "dominica": "دومینیکا", + "dominican_republic": "جمهوری دومینیکن", + "ecuador": "اکوادور", + "egypt": "مصر", + "el_salvador": "السالوادور", + "equatorial_guinea": "گینه استوایی", + "eritrea": "اریتره", + "estonia": "استونی", + "eswatini": "سوازیلند", + "ethiopia": "اتیوپی", + "fiji": "فیجی", + "finland": "فنلاند", + "france": "فرانسه", + "gabon": "گابن", + "gambia": "گامبیا", + "georgia": "گرجستان", + "germany": "آلمان", + "ghana": "غنا", + "greece": "یونان", + "guatemala": "گواتمالا", + "guinea": "گینه", + "guinea_bissau": "گینه بیسائو", + "guyana": "گویان", + "haiti": "هائیتی", + "honduras": "هندوراس", + "hungary": "مجارستان", + "iceland": "ایسلند", + "india": "هندوستان", + "indonesia": "اندونزی", + "iran": "ایران", + "iraq": "عراق", + "ireland": "ایرلند", + "israel": "اسرائیل", + "italy": "ایتالیا", + "jamaica": "جامائیکا", + "japan": "ژاپن", + "jordan": "اردن", + "kazakhstan": "قزاقستان", + "kenya": "کنیا", + "kuwait": "کویت", + "kyrgyzstan": "قرقیزستان", + "laos": "لائوس", + "latvia": "لتونی", + "lebanon": "لبنان", + "lesotho": "لسوتو", + "liberia": "لیبریا", + "libya": "لیبی", + "luxembourg": "لوکزامبورگ", + "malaysia": "مالزی", + "maldives": "مالدیو", + "mali": "مالی", + "malta": "مالت", + "mauritania": "موریتانی", + "mauritius": "موریس", + "mexico": "مکزیک", + "moldova": "مولداوی", + "monaco": "موناکو", + "mongolia": "مغولستان", + "morocco": "مراکش", + "mozambique": "موزامبیک", + "myanmar": "میانمار", + "namibia": "نامیبیا", + "nepal": "نپال", + "netherlands": "هلند", + "new_zealand": "نیوزیلند", + "nicaragua": "نیکاراگوئه", + "niger": "نیجر", + "nigeria": "نیجریه", + "north_korea": "کره شمالی", + "north_macedonia": "مقدونیه", + "norway": "نروژ", + "oman": "عمان", + "pakistan": "پاکستان", + "palau": "پالائو", + "panama": "پاناما", + "papua_new_guinea": "پاپوآ گینه نو", + "paraguay": "پاراگوئه", + "peru": "پرو", + "philippines": "فیلیپین", + "poland": "لهستان", + "portugal": "پرتغال", + "qatar": "قطر", + "romania": "رومانی", + "russia": "روسیه", + "rwanda": "رواندا", + "saudi_arabia": "عربستان سعودی", + "senegal": "سنگال", + "serbia": "صربستان", + "seychelles": "سیشل", + "sierra_leone": "سیرالئون", + "singapore": "سنگاپور", + "south_africa": "آفریقای جنوبی", + "south_korea": "کره جنوبی", + "south_sudan": "سودان جنوبی", + "spain": "اسپانیا", + "sri_lanka": "سری‌لانکا", + "sudan": "سودان", + "suriname": "سورینام", + "sweden": "سوئد", + "switzerland": "سوئیس", + "syria": "سوریه", + "taiwan": "تایوان", + "tajikistan": "تاجیکستان", + "tanzania": "تانزانیا", + "thailand": "تایلند", + "timor_leste": "تیمور شرقی", + "togo": "توگو", + "tonga": "تونگا", + "trinidad_and_tobago": "ترینیداد و توباگو", + "tunisia": "تونس", + "turkey": "ترکیه", + "turkmenistan": "ترکمنستان", + "tuvalu": "تووالو", + "uganda": "اوگاندا", + "ukraine": "اوکراین", + "united_arab_emirates": "امارات متحده عربی", + "united_kingdom": "انگلستان", + "united_states": "ایالات متحده آمریکا", + "uruguay": "اروگوئه", + "uzbekistan": "ازبکستان", + "vanuatu": "وانواتو", + "venezuela": "ونزوئلا", + "vietnam": "ویتنام", + "yemen": "یمن", + "zambia": "زامبیا", + "zimbabwe": "زیمبابوه" + }, + "messages": { + "noResualtFound": "نتیجه ای یافت نشد." + }, + "side": { + "account": "حساب کاربری", + "personalInfo": "اطلاعات شخصی", + "contactInfo": "شماره تماس", + "email": "ایمیل", + "security": "امنیت", + "password": "رمز عبور", + "confirmedIps": "آدرس های تایید شده", + "recentSessions": "ورود های اخیر", + "activeSessions": "نشست های فعال", + "setting": "تنظیمات" + } } diff --git a/src/App.tsx b/src/App.tsx index 1e33759..ec7f7b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,32 +1,17 @@ -import { CssBaseline, useColorScheme } from '@mui/material'; +import { CssBaseline } from '@mui/material'; import './App.css'; -import { LanguageManager } from './components/LanguageManager'; -import { UserCompletionForm } from './features/authentication/components/UserCompletionForm'; +import { LanguageManager } from '@/components/LanguageManager'; +import { RouterProvider } from 'react-router-dom'; +import { router } from '@/routes'; function App() { return ( <> - - + ); } export default App; - -import { Button } from '@mui/material'; - -export const ThemeToggleButton = () => { - const { mode, setMode } = useColorScheme(); - - return ( - - ); -}; diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx new file mode 100644 index 0000000..39f4eb9 --- /dev/null +++ b/src/components/Layout/Header.tsx @@ -0,0 +1,35 @@ +import { Box, IconButton, Typography } from '@mui/material'; +import { Icon } from '@rkheftan/harmony-ui'; +import { More } from 'iconsax-react'; +import type { User } from './type'; + +interface HeaderProps { + user: User; +} + +export const Header: React.FC = ({ user }) => { + return ( + t.spacing(10.5), + }} + > + + + {user.firstName + ' ' + user.lastName} + + + {user.phoneNumber} + + + + + + + + ); +}; diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..2652dfe --- /dev/null +++ b/src/components/Layout/Layout.tsx @@ -0,0 +1,69 @@ +import { SideNav } from '@rkheftan/harmony-ui'; +import { buildNavItems } from './buildNavItems'; +import { appRoutes } from '@/routes/config'; +import { Outlet, useLocation } from 'react-router-dom'; +import { Box, useMediaQuery, useTheme } from '@mui/material'; +import { Header } from './Header'; +import { useState } from 'react'; +import { Toolbar } from './Toolbar'; +import type { User } from './type'; + +export const Layout = () => { + const navItemConfigs = buildNavItems(appRoutes); + const location = useLocation(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const [sideNavOpen, setSideNavOpen] = useState(false); + const [user] = useState({ + firstName: 'محمد حسین', + lastName: 'برزه گر', + phoneNumber: '09123456789', + }); + + return ( + + + + + + + + setSideNavOpen(false)} + header={isMobile ? undefined :
} + footer={isMobile ?
: undefined} + navConfig={navItemConfigs} + activePath={location.pathname + location.hash} + selectedVariant="textOnly" + positioning="absolute" + sideNavVariant={isMobile ? 'temporary' : 'full'} + top={8.125} + /> + + + ); +}; diff --git a/src/components/Layout/Toolbar.tsx b/src/components/Layout/Toolbar.tsx new file mode 100644 index 0000000..46b482e --- /dev/null +++ b/src/components/Layout/Toolbar.tsx @@ -0,0 +1,75 @@ +import { + Avatar, + Box, + IconButton, + Toolbar as MuiToolbar, + Typography, +} from '@mui/material'; +import { Icon } from '@rkheftan/harmony-ui'; +import { HambergerMenu, Menu } from 'iconsax-react'; +import type { Dispatch, SetStateAction } from 'react'; +import type { User } from './type'; + +interface ToolbarProps { + sideNavOpen: boolean; + setSideNavOpen: Dispatch>; + isMobile: boolean; + user: User; +} + +export const Toolbar: React.FC = ({ + sideNavOpen, + setSideNavOpen, + isMobile, + user, +}) => { + return ( + t.spacing(isMobile ? 8 : 10.5), + px: isMobile ? 3 : 2, + borderBottom: (t) => `1px solid ${t.palette.divider}`, + boxSizing: 'content-box', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }} + > + + {isMobile && ( + setSideNavOpen(!sideNavOpen)} + > + + + )} + {/* */} + LOGO placeholder + + + {isMobile && ( + + {user.firstName.charAt(0) + ' ' + user.lastName.charAt(0)} + + )} + + + + + + ); +}; diff --git a/src/components/Layout/buildNavItems.tsx b/src/components/Layout/buildNavItems.tsx new file mode 100644 index 0000000..4cfaba0 --- /dev/null +++ b/src/components/Layout/buildNavItems.tsx @@ -0,0 +1,34 @@ +// src/components/SideNav.tsx (Conceptual Example) + +import { useTranslation } from 'react-i18next'; +import { type RouteConfig } from '@/routes/config'; +import { Icon, type NavItemConfig } from '@rkheftan/harmony-ui'; +import type { Icon as Iconsax } from 'iconsax-react'; + +const getIcon = (icon?: Iconsax) => (isSelected: boolean) => + icon ? ( + + ) : undefined; + +export function buildNavItems(routes: RouteConfig[]): NavItemConfig[] { + const { t } = useTranslation(); + + return routes.flatMap((route) => { + // Check if route itself does not have a navItem but its child has + if (!route.navConfig && route.children) { + return buildNavItems(route.children); + } + + // Check if route.navConfig is defined before destructuring + if (!route.navConfig) { + return []; // Return an empty array to be flattened + } + const { title, icon } = route.navConfig; + return { + text: t(title), + getIcon: getIcon(icon), + path: route.path, + children: route.children ? buildNavItems(route.children) : undefined, + }; + }); +} diff --git a/src/components/Layout/type.ts b/src/components/Layout/type.ts new file mode 100644 index 0000000..ab31407 --- /dev/null +++ b/src/components/Layout/type.ts @@ -0,0 +1,8 @@ +// TODO: this type file is temporary and should replace it with the actual use type and value when api is ready + +export interface User { + firstName: string; + lastName: string; + phoneNumber: string; + profileUrl?: string; +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 713c36f..d9376be 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -9,7 +9,11 @@ export interface ToastProps extends PropsWithChildren { export const Toast = ({ color, open, onClose, children }: ToastProps) => { return ( - + `calc(100% - ${t.spacing(6)})`, maxWidth: '396px' }} + open={open} + onClose={onClose} + > >; +} + +const DigitInput: React.FC = ({ + onChange, + error, + success, +}) => { + const [code, setCode] = useState(['', '', '', '']); + const inputRefs = useRef>([]); + + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + const handleDigitInputValueChange = (value: string[]) => { + const formatted = value.filter((char) => char !== '').join(''); + onChange(formatted); + }; + + const handleChange = (value: string, index: number) => { + if (!/^\d$/.test(value) && value !== '') return; + + const newCode = [...code]; + newCode[index] = value; + setCode(newCode); + handleDigitInputValueChange(newCode); + + if (value && index < 4 - 1) { + inputRefs.current[index + 1]?.focus(); + } + }; + + const handleBackspace = ( + event: KeyboardEvent, + index: number, + ) => { + event.preventDefault(); + if (index >= 0) { + handleChange('', index); + inputRefs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (event: React.ClipboardEvent) => { + event.preventDefault(); + const pastedData = event.clipboardData.getData('text').replace(/\D/g, ''); // Remove non-digit characters + const newCode = [...code]; + + pastedData.split('').forEach((digit, i) => { + if (i < code.length) { + newCode[i] = digit; + } + }); + + setCode(newCode); + handleDigitInputValueChange(newCode); + + // Focus the next empty input after the last pasted character + const lastIndex = Math.min(pastedData.length, code.length) - 1; + if (lastIndex >= 0 && inputRefs.current[lastIndex]) { + inputRefs.current[lastIndex]?.focus(); + } + }; + + return ( + + {code.map((digit, index) => ( + (inputRefs.current[index] = el)} + value={digit} + onChange={(e) => handleChange(e.target.value, index)} + onKeyDown={(e) => e.key === 'Backspace' && handleBackspace(e, index)} + onPaste={(e) => handlePaste(e)} + slotProps={{ + htmlInput: { + maxLength: 1, + sx: { + height: '72px', + color: error + ? 'error.main' + : success + ? 'success.main' + : 'text.primary', + }, + style: { + textAlign: 'center', + fontSize: '48px', + }, + }, + }} + variant="standard" + size="medium" + sx={{ + width: '83px', + }} + /> + ))} + + ); +}; + +export default DigitInput; diff --git a/src/components/components/common/Container.tsx b/src/components/components/common/Container.tsx new file mode 100644 index 0000000..c9efc78 --- /dev/null +++ b/src/components/components/common/Container.tsx @@ -0,0 +1,8 @@ +import { Box, styled } from '@mui/material'; + +export const Container = styled(Box)(() => ({ + width: '100%', + maxWidth: '100vw', + height: '100vh', + margin: '0 auto', +})); diff --git a/src/components/components/common/FlexBox.tsx b/src/components/components/common/FlexBox.tsx new file mode 100644 index 0000000..e15bb8c --- /dev/null +++ b/src/components/components/common/FlexBox.tsx @@ -0,0 +1,21 @@ +import { Box, styled, type BoxProps } from '@mui/material'; + +// Define the props our component will accept +interface FlexBoxProps extends BoxProps { + direction?: 'row' | 'column'; + justify?: string; + align?: string; +} + +export const FlexBox = styled(Box, { + // Do not forward these custom props to the DOM element + shouldForwardProp: (prop) => + prop !== 'direction' && prop !== 'justify' && prop !== 'align', +})( + ({ direction = 'row', justify = 'flex-start', align = 'stretch' }) => ({ + display: 'flex', + flexDirection: direction, + justifyContent: justify, + alignItems: align, + }), +); diff --git a/src/components/components/common/Stack.tsx b/src/components/components/common/Stack.tsx new file mode 100644 index 0000000..0e00bfd --- /dev/null +++ b/src/components/components/common/Stack.tsx @@ -0,0 +1,19 @@ +import { Box, styled, type BoxProps } from '@mui/material'; + +interface StackProps extends BoxProps { + direction?: 'row' | 'column'; + spacing?: number; // Spacing factor (multiplied by theme.spacing) + align?: string; +} + +export const Stack = styled(Box, { + shouldForwardProp: (prop) => + prop !== 'direction' && prop !== 'spacing' && prop !== 'align', +})( + ({ theme, direction = 'column', spacing = 2, align = 'stretch' }) => ({ + display: 'flex', + flexDirection: direction, + alignItems: align, + gap: theme.spacing(spacing), + }), +); diff --git a/src/countries.ts b/src/countries.ts new file mode 100644 index 0000000..a2a0166 --- /dev/null +++ b/src/countries.ts @@ -0,0 +1,267 @@ +import type { CountryCode } from '@/types/commonTypes'; + +export interface Country { + code: string; + label: string; + phone: CountryCode; +} + +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' }, +]; diff --git a/src/features/authorization/api/authorizationAPI.ts b/src/features/authorization/api/authorizationAPI.ts new file mode 100644 index 0000000..51b1dfe --- /dev/null +++ b/src/features/authorization/api/authorizationAPI.ts @@ -0,0 +1,104 @@ +import type { ApiResponse } from '@/types/apiResponse'; +import type { FetchPromise } from '@/types/fetchPromise'; +import type { + CompleteUserInformationRequest, + ConfirmEmailOtpRequest, + ConfirmForgetPassCodeRequest, + ConfirmOtpResponse, + ConfirmSmsOtpRequest, + GetUserStatusByPhoneNumberOrEmailRequest, + GetUserStatusByPhoneNumberOrEmailResponse, + LoginOrSignUpWithGoogleRequest, + LoginOrSignUpWithGoogleResponse, + LoginRequest, + LoginResponse, + PasswordLoginRequest, + ResetPasswordRequest, + ResetPasswordResponse, + SendEmailOtpRequest, + SendForgetPassCodeRequest, + SendSmsOtpRequest, +} from '../types/userTypes'; + +const API_URL = 'https://accounts.business-harmony.com/api'; + +export const fetchRequest = ( + url: string, + body: Object | null, +): FetchPromise => { + return fetch(`${API_URL}/${url}`, { + body: JSON.stringify(body), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); +}; + +// GetUserStatusByPhoneNumberOrEmail + +export const getUserStatusByPhoneNumberOrEmail = async ( + body: GetUserStatusByPhoneNumberOrEmailRequest, +) => { + return fetchRequest( + 'User/GetUserStatusByPhoneNumberOrEmail', + body, + ); +}; + +export const loginOrSignUpWithOtp = async (body: LoginRequest) => { + return fetchRequest('User/LoginOrSignUpWithOtp', body); +}; + +export const loginWithPassword = async (body: PasswordLoginRequest) => { + return fetchRequest('User/LoginWithPassword', body); +}; + +export const sendSmsOtp = async (body: SendSmsOtpRequest) => { + return fetchRequest('User/SendSmsOtp', body); +}; + +export const sendEmailOtp = async (body: SendEmailOtpRequest) => { + return fetchRequest('User/SendEmailOtp', body); +}; + +export const confirmSmsOtp = async (body: ConfirmSmsOtpRequest) => { + return fetchRequest('User/ConfirmSmsOtp', body); +}; + +export const confirmEmailOtp = async (body: ConfirmEmailOtpRequest) => { + return fetchRequest('User/ConfirmEmailOtp', body); +}; + +export const resetPassword = async (body: ResetPasswordRequest) => { + return fetchRequest('User/ResetPassword', body); +}; + +export const sendForgetPassCode = async (body: SendForgetPassCodeRequest) => { + return fetchRequest('User/SendForgetPassCode', body); +}; + +export const confirmForgetPassCode = async ( + body: ConfirmForgetPassCodeRequest, +) => { + return fetchRequest('User/ConfirmForgetPassCode', body); +}; + +export const loginOrSignUpWithGoogle = async ( + body: LoginOrSignUpWithGoogleRequest, +) => { + return fetchRequest( + 'User/LoginOrSignUpWithGoogle', + body, + ); +}; + +export const completeUserInformation = async ( + body: CompleteUserInformationRequest, +) => { + return fetchRequest('User/CompleteUserInformation', body); +}; + +export const logOut = async () => { + return fetchRequest('User/LogOut', {}); +}; diff --git a/src/features/authorization/components/AuthenticationCard.tsx b/src/features/authorization/components/AuthenticationCard.tsx new file mode 100644 index 0000000..6c49575 --- /dev/null +++ b/src/features/authorization/components/AuthenticationCard.tsx @@ -0,0 +1,23 @@ +import { Paper } from '@mui/material'; +import { type PropsWithChildren } from 'react'; + +// Beacuse in the otp verify there is a element outside of the authentication card +export const AuthenticationCard = ({ children }: PropsWithChildren) => { + return ( + `calc(100% - ${t.spacing(2)})`, + maxWidth: '552px', + }} + > + {children} + + ); +}; diff --git a/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx b/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx new file mode 100644 index 0000000..e02bd3a --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx @@ -0,0 +1,139 @@ +import { useState, type JSX } from 'react'; +import { LoginRegisterForm } from './LoginRegiserForm'; +import type { AuthMode, AuthStep, AuthType } from '../../types/authTypes'; +import { OtpVerifyForm } from './OtpVerifyForm'; +import { isNumeric } from '@/utils/regexes/isNumeric'; +import { CompleteSignUp } from './CompleteSignUp'; +import { EnterPasswordForm } from './EnterPasswordForm'; +import { UserStatus } from '../../types/userTypes'; +import type { CountryCode, GUID } from '@/types/commonTypes'; +import { VerifyPhoneNumber } from './VerifyPhoneNumber'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +export const AuthenticationSteps = (): JSX.Element => { + const navigate = useNavigate(); + const DEFAULT_RETURN_URL = '/profile'; + const [searchParams] = useSearchParams(); + const authReturnUrl: string = + searchParams.get('returnUrl') ?? DEFAULT_RETURN_URL; + const [authMode, setAuthMode] = useState('register'); + const [authType, setAuthType] = useState('phone'); + const [currentStep, setCurrentStep] = useState('emailOrPhone'); + const [loginRegisterValue, setLoginRegisterValue] = useState(''); + const [countryCode, setCountryCode] = useState('+98'); + const [addPhoneCountryCode, setAddPhoneCountryCode] = + useState('+98'); + const [addedPhoneNumberValue, setAddedPhoneNumberValue] = + useState(''); + + const handleLoginRegister = (value: string, userStatus: UserStatus) => { + setAuthType(isNumeric(value) ? 'phone' : 'email'); + + switch (userStatus) { + case UserStatus.NotRegistered: + setAuthMode('register'); + setCurrentStep('verify'); + break; + + case UserStatus.RegisteredWithoutPassword: + setAuthMode('login'); + setCurrentStep('verify'); + + break; + + case UserStatus.RegisteredWithPassword: + setAuthMode('login'); + setCurrentStep('enterPassword'); + + break; + } + }; + + const handleUserLoggedIn = (userId: GUID) => { + localStorage.setItem('userID', userId); + + redirectToReturnUrl(); + }; + + const handleConfrimPhoneNumber = (userId: GUID) => { + localStorage.setItem('userID', userId); + + setCurrentStep('addPhoneNumber'); + }; + + const handlePhoneNumberVerified = () => { + redirectToReturnUrl(); + }; + + const redirectToReturnUrl = () => { + if (authReturnUrl === DEFAULT_RETURN_URL) { + navigate(DEFAULT_RETURN_URL); + } else { + location.href = authReturnUrl; + } + }; + + return ( + <> + {currentStep === 'emailOrPhone' && ( + + )} + + {currentStep === 'verify' && ( + setCurrentStep('emailOrPhone')} + authMode={authMode} + authType={authType} + value={loginRegisterValue} + /> + )} + + {currentStep === 'enterPassword' && ( + setCurrentStep('emailOrPhone')} + onLoginWithOTP={() => setCurrentStep('verify')} + emailOrPhone={loginRegisterValue} + /> + )} + + {currentStep === 'addPhoneNumber' && ( + setCurrentStep('addedPhoneNumberVerify')} + /> + )} + + {currentStep === 'addedPhoneNumberVerify' && ( + setCurrentStep('addPhoneNumber')} + value={addedPhoneNumberValue} + onPhoneNumberVerified={handlePhoneNumberVerified} + /> + )} + + ); +}; diff --git a/src/features/authorization/components/AuthenticationSteps/CompleteSignUp.tsx b/src/features/authorization/components/AuthenticationSteps/CompleteSignUp.tsx new file mode 100644 index 0000000..132f136 --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/CompleteSignUp.tsx @@ -0,0 +1,118 @@ +import { Button, TextField, Typography } from '@mui/material'; +import parsePhoneNumberFromString from 'libphonenumber-js'; +import { useRef, useState, type Dispatch } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AuthenticationCard } from '../AuthenticationCard'; +import { CountryCodeSelector } from '../CountryCodeSelector'; +import { sendSmsOtp } from '../../api/authorizationAPI'; +import type { CountryCode } from '@/types/commonTypes'; + +export interface CompleteSignUpProps { + email: string; + value: string; + setValue: Dispatch; + countryCode: CountryCode; + setCountryCode: Dispatch; + onCompleteSignUp: (countryCode: string, value: string) => void; +} + +export const CompleteSignUp = ({ + email, + value, + setValue, + countryCode, + setCountryCode, + onCompleteSignUp, +}: CompleteSignUpProps) => { + const { t } = useTranslation('authentication'); + const [error, setError] = useState(); + const textFieldRef = useRef(null); + const inputRef = useRef(null); + const [touched, setTouched] = useState(false); + const inputError: boolean = touched && !!error; + const [sendOtpLoading, setSendOtpLoading] = useState(false); + + const isPhoneValid = (code: string, phone: string) => { + const phoneNumber = parsePhoneNumberFromString(code + phone); + + return phoneNumber && phoneNumber.isValid(); + }; + + const handleBlur = () => { + setTouched(true); + + handleValueError(); + }; + + const handleValueError = () => { + if (!value) { + setError(t('loginForm.thisFieldIsRequired')); + } + if (!isPhoneValid(countryCode, value)) { + setError(t('loginForm.phoneNumberIsInvalid')); + } else { + setError(undefined); + } + }; + + const handleCompleteSignUp = async () => { + handleValueError(); + + if (!value || !isPhoneValid(countryCode, value)) { + inputRef.current?.focus(); + } else { + setSendOtpLoading(true); + + await sendSmsOtp({ phoneNumber: countryCode + value }); + onCompleteSignUp(countryCode, value); + + setSendOtpLoading(false); + } + }; + + return ( + + + {t('completeSignUp.completeSignUp')} + + + + {t( + 'completeSignUp.emailHasBeenSuccessfullyVerifiedPleaseEnterYourContactNumberToContinue', + { email }, + )} + + + setValue(e.target.value)} + onBlur={handleBlur} + error={inputError} + helperText={inputError ? error : ''} + autoFocus + slotProps={{ + htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } }, + input: { + endAdornment: ( + + ), + }, + }} + sx={{ my: 4 }} + /> + + + + ); +}; diff --git a/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx b/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx new file mode 100644 index 0000000..d5b9b89 --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx @@ -0,0 +1,190 @@ +import { useRef, useState } from 'react'; +import { AuthenticationCard } from '../AuthenticationCard'; +import { ArrowLeft, Edit2, Eye, EyeSlash } from 'iconsax-react'; +import { + Box, + Button, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Toast } from '@/components/Toast'; +import { Link } from 'react-router-dom'; +import type { AuthType } from '../../types/authTypes'; +import type { CountryCode, GUID } from '@/types/commonTypes'; +import { + loginWithPassword, + sendEmailOtp, + sendSmsOtp, +} from '../../api/authorizationAPI'; +import type { PasswordLoginRequest } from '../../types/userTypes'; + +export interface EnterPasswordFormProps { + onEditValue: () => void; + onLoginWithOTP: () => void; + onLoggedIn: (userId: GUID) => void; + emailOrPhone: string; + authType: AuthType; + loginRegisterValue: string; + countryCode: CountryCode; + authReturnUrl: string; +} + +export const EnterPasswordForm = ({ + onEditValue, + onLoginWithOTP, + onLoggedIn, + emailOrPhone, + authType, + loginRegisterValue, + countryCode, + authReturnUrl, +}: EnterPasswordFormProps) => { + const { t } = useTranslation('authentication'); + const [passValue, setPassValue] = useState(''); + const [inputTouched, setInputTouched] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const inputRef = useRef(null); + const [loginLoading, setLoginLoading] = useState(false); + const [isLoginStatusSuccess, setIsLoginStatusSuccess] = useState(); + const [loginAlertOpen, setLoginAlertOpen] = useState(false); + const [loginFailedMessage, setLoginFailedMessage] = useState(''); + const [sendOtpLoading, setSendOtpLoading] = useState(false); + + const handleBlur = () => { + setInputTouched(true); + }; + + const handleSubmit = async () => { + if (!passValue) { + inputRef.current?.focus(); + } else { + setLoginLoading(true); + + const apiRequest: PasswordLoginRequest = { + phoneNumber: + authType === 'phone' ? countryCode + loginRegisterValue : undefined, + email: authType === 'email' ? loginRegisterValue : undefined, + password: passValue, + returnUrl: authReturnUrl, + }; + const result = await loginWithPassword(apiRequest); + const jsonRes = await result.json(); + + if (jsonRes.success) { + setIsLoginStatusSuccess(true); + onLoggedIn(jsonRes.userId); + } else { + setIsLoginStatusSuccess(false); + setLoginFailedMessage(jsonRes.message); + } + setLoginAlertOpen(true); + setLoginLoading(false); + } + }; + + const handleLoginWithOtp = async () => { + setSendOtpLoading(true); + + if (authType === 'phone') { + await sendSmsOtp({ phoneNumber: countryCode + loginRegisterValue }); + } else { + await sendEmailOtp({ email: loginRegisterValue }); + } + + setSendOtpLoading(false); + onLoginWithOTP(); + }; + + return ( + + setLoginAlertOpen(false)} + color={!isLoginStatusSuccess ? 'error' : 'success'} + > + {!isLoginStatusSuccess + ? loginFailedMessage + : t('verify.youHaveSuccessfullyLoggedIn')} + + + + + {t('enterPassword.loginWithPassword')} + + + + + + + {t('enterPassword.enterThePasswordYouSetForYourAccount')} + + + setPassValue(e.target.value)} + onBlur={handleBlur} + error={!passValue && inputTouched} + helperText={ + !passValue && inputTouched ? t('loginForm.thisFieldIsRequired') : '' + } + autoFocus + slotProps={{ + htmlInput: { sx: { lineHeight: 1.5 } }, + input: { + endAdornment: ( + setShowPassword(!showPassword)} + > + {showPassword ? : } + + ), + }, + }} + sx={{ my: 4 }} + /> + + + + + + + + + + + ); +}; diff --git a/src/features/authorization/components/AuthenticationSteps/GoogleAuthentication.tsx b/src/features/authorization/components/AuthenticationSteps/GoogleAuthentication.tsx new file mode 100644 index 0000000..9a74826 --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/GoogleAuthentication.tsx @@ -0,0 +1,81 @@ +import { Button } from '@mui/material'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { GoogleCodeClientResponse } from '../../types/userTypes'; +import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI'; +import type { GUID } from '@/types/commonTypes'; +import { Google } from 'iconsax-react'; + +export interface GoogleAuthenticationProps { + disabled: boolean; + authReturnUrl: string; + onGoogleAuthenticated: (userId: GUID) => void; +} + +export const GoogleAuthentication = ({ + disabled, + authReturnUrl, + onGoogleAuthenticated, +}: GoogleAuthenticationProps) => { + const { t } = useTranslation('authentication'); + const [loginWithGoogleLoading, setLoginWithGoogleLoading] = + useState(false); + + const clientRef = useRef(null); + + useEffect(() => { + const script = document.createElement('script'); + script.src = 'https://accounts.google.com/gsi/client'; + script.async = true; + script.defer = true; + document.body.appendChild(script); + + script.onload = () => { + clientRef.current = google.accounts.oauth2.initCodeClient({ + client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, + scope: 'openid email profile', + ux_mode: 'popup', + response_type: 'id_token', + callback: async (resp: GoogleCodeClientResponse) => { + setLoginWithGoogleLoading(true); + + const result = await loginOrSignUpWithGoogle({ + idToken: resp.id_token, + returnUrl: authReturnUrl, + }); + const jsonRes = await result.json(); + + if (jsonRes.success) { + onGoogleAuthenticated(jsonRes.userId); + } else { + // Todo: Add useToast to handle error + } + + setLoginWithGoogleLoading(false); + }, + }); + }; + + return () => { + document.body.removeChild(script); + }; + }, []); + + const handleGoogleLogin = () => { + if (clientRef.current) { + clientRef.current.requestCode(); + } + }; + + return ( + + ); +}; diff --git a/src/features/authorization/components/AuthenticationSteps/LoginRegiserForm.tsx b/src/features/authorization/components/AuthenticationSteps/LoginRegiserForm.tsx new file mode 100644 index 0000000..0b7e1aa --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/LoginRegiserForm.tsx @@ -0,0 +1,170 @@ +import { Button, Stack, TextField, Typography } from '@mui/material'; +import { useRef, useState, type Dispatch } from 'react'; +import { useTranslation } from 'react-i18next'; +import { isNumeric } from '@/utils/regexes/isNumeric'; +import type { AuthType } from '../../types/authTypes'; +import { isEmail } from '@/utils/regexes/isEmail'; +import { AuthenticationCard } from '../AuthenticationCard'; +import { CountryCodeSelector } from '../CountryCodeSelector'; +import type { UserStatus } from '../../types/userTypes'; +import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI'; +import { Toast } from '@/components/Toast'; +import type { CountryCode, GUID } from '@/types/commonTypes'; +import { GoogleAuthentication } from './GoogleAuthentication'; +import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber'; + +export interface LoginRegisterFormProps { + loginRegisterValue: string; + setLoginRegisterValue: Dispatch; + countryCode: CountryCode; + setCountryCode: Dispatch; + authType: AuthType; + setAuthType: Dispatch; + onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void; + authReturnUrl: string; + onGoogleAuthenticated: (userId: GUID) => void; +} + +export function LoginRegisterForm({ + loginRegisterValue, + setLoginRegisterValue, + countryCode, + setCountryCode, + authType, + setAuthType, + onLoginRegisterSubmit, + authReturnUrl, + onGoogleAuthenticated, +}: LoginRegisterFormProps) { + const [checkStatusLoading, setCheckStatusLoading] = useState(false); + const { t } = useTranslation('authentication'); + const textFieldRef = useRef(null); + const inputRef = useRef(null); + const [error, setError] = useState(); + const [touched, setTouched] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const inputError: boolean = touched && !!error; + + const handleInputChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + setLoginRegisterValue(newValue); + + // If the new value contains only digits (or is empty), it's a phone number + if (isNumeric(newValue)) { + setAuthType('phone'); + } else { + setAuthType('email'); + } + }; + + const handleBlur = () => { + setTouched(true); + validateInput(loginRegisterValue, authType); + }; + + const validateInput = ( + value: string, + authType: AuthType, + setErrors: boolean = true, + ): boolean => { + if (!value) { + if (setErrors) setError(t('loginForm.thisFieldIsRequired')); + return false; + } + + if (authType === 'email' && !isEmail(value)) { + if (setErrors) setError(t('loginForm.emailIsInvalid')); + return false; + } + + if (authType === 'phone' && !isPhoneNumber(countryCode, value)) { + if (setErrors) setError(t('loginForm.phoneNumberIsInvalid')); + return false; + } + + if (setErrors) setError(undefined); + return true; + }; + + const handleSubmit = async () => { + if (validateInput(loginRegisterValue, authType, false)) { + setCheckStatusLoading(true); + const result = await getUserStatusByPhoneNumberOrEmail({ + phoneNumber: + authType === 'phone' ? countryCode + loginRegisterValue : undefined, + email: authType === 'email' ? loginRegisterValue : undefined, + }); + const jsonResult = await result.json(); + + if (jsonResult.success) { + onLoginRegisterSubmit(loginRegisterValue, jsonResult.userStatus); + } else { + setErrorMessage(jsonResult.message); + } + setCheckStatusLoading(false); + } else { + inputRef.current?.focus(); + validateInput(loginRegisterValue, authType); + } + }; + + const showAdornment = authType === 'phone' && loginRegisterValue.length > 0; + + return ( + + setErrorMessage(undefined)} + open={!!errorMessage} + > + {errorMessage} + + + + {t('loginForm.title')} + + {t('loginForm.description')} + + + + + ), + }, + }} + sx={{ my: 4 }} + /> + + + + + + + + ); +} diff --git a/src/features/authorization/components/AuthenticationSteps/OtpVerifyForm.tsx b/src/features/authorization/components/AuthenticationSteps/OtpVerifyForm.tsx new file mode 100644 index 0000000..25c0c26 --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/OtpVerifyForm.tsx @@ -0,0 +1,225 @@ +import { useTranslation } from 'react-i18next'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { Edit2 } from 'iconsax-react'; +import DigitInput from '@/components/components/DigitsInput'; +import type { AuthMode, AuthType } from '../../types/authTypes'; +import { useEffect, useState } from 'react'; +import { Toast } from '@/components/Toast'; +import { AuthenticationCard } from '../AuthenticationCard'; +import type { LoginRequest } from '../../types/userTypes'; +import { + loginOrSignUpWithOtp, + sendEmailOtp, + sendSmsOtp, +} from '../../api/authorizationAPI'; +import type { CountryCode, GUID } from '@/types/commonTypes'; + +interface OtpVerifyFormProps { + value: string; + countryCode: CountryCode; + authType: AuthType; + authMode: AuthMode; + onEditValue: () => void; + onOTPVerified: (userId: GUID) => void; + onVerifyPhoneNumber: (userId: GUID) => void; + authReturnUrl: string; +} + +export function OtpVerifyForm({ + value, + countryCode, + authType, + authMode, + onEditValue, + onOTPVerified, + onVerifyPhoneNumber, + authReturnUrl, +}: OtpVerifyFormProps) { + const [otpCode, setOtpCode] = useState(''); + const [otpDigitInvalid, setOtpDigitInvalid] = useState(false); + const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>(); + const [errorMessage, setErrorMessage] = useState(); + const [verifyStatusLoading, setVerifyStatusLoading] = + useState(false); + const [verifyAlertOpen, setVerifyAlertOpen] = useState(false); + const { t } = useTranslation('authentication'); + const [resendTimer, setResendTimer] = useState(120); + const [canResend, setCanResend] = useState(false); + const [resendLoading, setResendLoading] = useState(false); + + useEffect(() => { + let interval: NodeJS.Timeout; + if (resendTimer > 0) { + interval = setInterval(() => { + setResendTimer((prev) => prev - 1); + }, 1000); + } else { + setCanResend(true); + } + + return () => clearInterval(interval); + }, [resendTimer]); + + const handleResendOTPCode = async () => { + setResendLoading(true); + + if (authType === 'phone') { + await sendSmsOtp({ phoneNumber: countryCode + value }); + } else { + await sendEmailOtp({ email: value }); + } + + setResendTimer(120); + setCanResend(false); + setResendLoading(false); + }; + + const formatTime = (seconds: number) => { + const min = Math.floor(seconds / 60); + const sec = seconds % 60; + return `${min}:${sec.toString().padStart(2, '0')}`; + }; + + const handleVerifyOTP = () => { + if (!otpCode || otpCode.length < 4) { + setOtpDigitInvalid(true); + } else { + handleLoginOrSignUp(); + } + }; + + const handleLoginOrSignUp = async () => { + setOtpDigitInvalid(false); + setVerifyStatusLoading(true); + + const loginRequest: LoginRequest = { + otpCode: otpCode, + phoneNumber: authType === 'phone' ? countryCode + value : undefined, + email: authType === 'email' ? value : undefined, + returnUrl: authReturnUrl, + }; + const result = await loginOrSignUpWithOtp(loginRequest); + const jsonRes = await result.json(); + + if (jsonRes.success) { + setVerifyStatus('success'); + + if (jsonRes.registeredWithOutPhoneNumber) { + onVerifyPhoneNumber(jsonRes.userId); + } else { + onOTPVerified(jsonRes.userId); + } + } else { + setVerifyStatus('failed'); + setErrorMessage(jsonRes.message); + } + + setVerifyAlertOpen(true); + setVerifyStatusLoading(false); + }; + + const otpMessage = (): string => { + if (authType === 'phone' && authMode === 'login') { + return t( + 'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt', + ); + } else if (authType === 'phone' && authMode === 'register') { + return t( + 'verify.thereIsNoAccountWithThisNumberA4DigitVerificationCodeHasBeenSentToThisNumberToCreateANewAccount', + ); + } else if (authType === 'email' && authMode === 'login') { + return t( + 'verify.a4digitVerificationCodeHasBeenSentToYourEmailAddressPleaseEnterIt', + ); + } else if (authType === 'email' && authMode === 'register') { + return t( + 'verify.thereIsNoAccountWithThisEmailAddressA4DigitVerificationCodeHasBeenSentToThisEmailAddressToCreateANewAccount', + ); + } + + return ''; + }; + + const toastMessage = + verifyStatus === 'failed' + ? (errorMessage ?? t('verify.theVerificationCodeIsIncorrect')) + : verifyStatus === 'success' && authMode === 'register' + ? t('verify.youHaveSuccessfullySignedIn') + : verifyStatus === 'success' && authMode === 'login' + ? t('verify.youHaveSuccessfullyLoggedIn') + : ''; + + return ( + + + setVerifyAlertOpen(false)} + color={verifyStatus === 'failed' ? 'error' : 'success'} + > + {toastMessage} + + + + {t('verify.verify')} + + + + + + {otpMessage()} + + + setOtpCode(value)} + /> + + + + + + {t('verify.resendCodeIn')} + + + + + ); +} diff --git a/src/features/authorization/components/AuthenticationSteps/VerifyPhoneNumber.tsx b/src/features/authorization/components/AuthenticationSteps/VerifyPhoneNumber.tsx new file mode 100644 index 0000000..ad937d4 --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/VerifyPhoneNumber.tsx @@ -0,0 +1,174 @@ +import { useTranslation } from 'react-i18next'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { Edit2 } from 'iconsax-react'; +import DigitInput from '@/components/components/DigitsInput'; +import { useEffect, useState } from 'react'; +import { Toast } from '@/components/Toast'; +import { AuthenticationCard } from '../AuthenticationCard'; +import type { ConfirmSmsOtpRequest } from '../../types/userTypes'; +import { confirmSmsOtp, sendSmsOtp } from '../../api/authorizationAPI'; +import type { CountryCode } from '@/types/commonTypes'; + +interface VerifyPhoneNumberProps { + value: string; + countryCode: CountryCode; + onEditValue: () => void; + onPhoneNumberVerified: () => void; +} + +export function VerifyPhoneNumber({ + value, + countryCode, + onEditValue, + onPhoneNumberVerified, +}: VerifyPhoneNumberProps) { + const [otpCode, setOtpCode] = useState(''); + const [otpDigitInvalid, setOtpDigitInvalid] = useState(false); + const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>(); + const [errorMessage, setErrorMessage] = useState(); + const [verifyStatusLoading, setVerifyStatusLoading] = + useState(false); + const [verifyAlertOpen, setVerifyAlertOpen] = useState(false); + const { t } = useTranslation('authentication'); + const [resendTimer, setResendTimer] = useState(120); + const [canResend, setCanResend] = useState(false); + const [resendLoading, setResendLoading] = useState(false); + + useEffect(() => { + let interval: NodeJS.Timeout; + if (resendTimer > 0) { + interval = setInterval(() => { + setResendTimer((prev) => prev - 1); + }, 1000); + } else { + setCanResend(true); + } + + return () => clearInterval(interval); + }, [resendTimer]); + + const handleResendOTPCode = async () => { + setResendLoading(true); + + await sendSmsOtp({ phoneNumber: countryCode + value }); + + setResendTimer(120); + setCanResend(false); + setResendLoading(false); + }; + + const formatTime = (seconds: number) => { + const min = Math.floor(seconds / 60); + const sec = seconds % 60; + return `${min}:${sec.toString().padStart(2, '0')}`; + }; + + const handleVerifyOTP = async () => { + if (!otpCode || otpCode.length < 4) { + setOtpDigitInvalid(true); + } else { + setOtpDigitInvalid(false); + setVerifyStatusLoading(true); + + const confirmSmsOtpRequest: ConfirmSmsOtpRequest = { + otpCode: otpCode, + phoneNumber: countryCode + value, + }; + const result = await confirmSmsOtp(confirmSmsOtpRequest); + const jsonRes = await result.json(); + + if (jsonRes.success) { + setVerifyStatus('success'); + onPhoneNumberVerified(); + } else { + setVerifyStatus('failed'); + setErrorMessage(jsonRes.message); + } + + setVerifyAlertOpen(true); + setVerifyStatusLoading(false); + } + }; + + const verifyAlertMessage = (): string => { + if (verifyStatus === 'failed') { + return errorMessage ?? t('verify.theVerificationCodeIsIncorrect'); + } else { + return t('verify.youHaveSuccessfullyLoggedIn'); + } + }; + + return ( + + + setVerifyAlertOpen(false)} + color={verifyStatus === 'failed' ? 'error' : 'success'} + > + {verifyAlertMessage()} + + + + {t('verify.verify')} + + + + + + {t( + 'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt', + )} + + + setOtpCode(value)} + /> + + + + + + {t('verify.resendCodeIn')} + + + + + ); +} diff --git a/src/features/authorization/components/CountryCodeSelector.tsx b/src/features/authorization/components/CountryCodeSelector.tsx new file mode 100644 index 0000000..a93168b --- /dev/null +++ b/src/features/authorization/components/CountryCodeSelector.tsx @@ -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 '../../../countries'; +import type { CountryCode } from '@/types/commonTypes'; +interface CountryCodeSelectorProps { + show: boolean; + value: CountryCode; + onChange: (newValue: CountryCode) => void; + menuAnchor: HTMLElement | null; + onCloseFocusRef: RefObject; +} + +/** + * 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); + const [searchTerm, setSearchTerm] = useState(''); + const open = Boolean(anchorEl); + const searchInputRef = useRef(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], + ); + + return ( + + + 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 */} + + + + {value} + + + + + + + setSearchTerm(e.target.value)} + /> + + + {/* Can improve preformance with using virtual scrolling */} + + {filteredCountries.length === 0 ? ( + + + {t('messages.noResualtFound')} + + + ) : ( + filteredCountries.map((country) => ( + handleSelect(country)} + > + + + + + + {country.phone} + + + )) + )} + + + {/* virtual scrolling */} + {/* ( + + {t('messages.noResultFound')} + + ), + }} + initialTopMostItemIndex={countries.indexOf(selectedCountry)} + itemContent={(_, country) => ( + handleSelect(country)} + > + + + + + + {country.phone} + + + )} + /> */} + + + + ); +} diff --git a/src/features/authorization/components/ForgetPassword/ChangePassword.tsx b/src/features/authorization/components/ForgetPassword/ChangePassword.tsx new file mode 100644 index 0000000..6da9c4d --- /dev/null +++ b/src/features/authorization/components/ForgetPassword/ChangePassword.tsx @@ -0,0 +1,254 @@ +import { useRef, useState } from 'react'; +import { AuthenticationCard } from '../AuthenticationCard'; +import { Edit2, Eye, EyeSlash, TickCircle } from 'iconsax-react'; +import { + Box, + Button, + IconButton, + Stack, + TextField, + Typography, + useTheme, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Toast } from '@/components/Toast'; +import { containsNumber } from '@/utils/regexes/containsNumber'; +import { containsSymbol } from '@/utils/regexes/containsSymbol'; +import { least8Chars } from '@/utils/regexes/least8Chars'; +import { hasUpperAndLowerLetter } from '@/utils/regexes/hasUpperAndLowerLetter'; +import type { ResetPasswordRequest } from '../../types/userTypes'; +import type { AuthType } from '../../types/authTypes'; +import type { CountryCode } from '@/types/commonTypes'; +import { resetPassword } from '../../api/authorizationAPI'; + +export interface ChangePasswordProps { + onEditInfo: () => void; + onPasswordChanged: () => void; + forgettedPasswordInfo: string; + infoType: AuthType; + countryCode: CountryCode; +} + +export const ChangePassword = ({ + onEditInfo, + onPasswordChanged, + forgettedPasswordInfo, + infoType, + countryCode, +}: ChangePasswordProps) => { + const theme = useTheme(); + const { t } = useTranslation('authentication'); + const [passValue, setPassValue] = useState(''); + const [confirmPassValue, setConfirmPassValue] = useState(''); + const [inputTouched, setInputTouched] = useState(false); + const [confirmInputTouched, setConfirmInputTouched] = + useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = + useState(false); + const inputRef = useRef(null); + const confirmInputRef = useRef(null); + const [changePasswordLoading, setChangePasswordLoading] = + useState(false); + const [changePasswordStatus, setChangePasswordStatus] = useState< + 'success' | 'failed' + >(); + const [changePassAlertOpen, setChangePassAlertOpen] = + useState(false); + const [changePassFailedMessage, setChangePassFailedMessage] = + useState(''); + + const passwordValidationRules = [ + { title: t('forgetPassword.includingANumber'), validator: containsNumber }, + { title: t('forgetPassword.atLeast8Characters'), validator: least8Chars }, + { + title: t('forgetPassword.containsAnUppercaseAndLowercaseLetter'), + validator: hasUpperAndLowerLetter, + }, + { title: t('forgetPassword.ContainsASymbol'), validator: containsSymbol }, + ]; + + const handleBlur = () => { + setInputTouched(true); + }; + + const handleConfirmPassBlur = () => { + setConfirmInputTouched(true); + }; + + const handleSubmit = async () => { + if (!passValue || !isValidPassword(passValue)) { + setInputTouched(true); + inputRef.current?.focus(); + } else if (passValue !== confirmPassValue) { + setConfirmInputTouched(true); + confirmInputRef.current?.focus(); + } else { + setChangePasswordLoading(true); + + const apiRequest: ResetPasswordRequest = { + email: infoType === 'email' ? forgettedPasswordInfo : undefined, + phoneNumber: + infoType === 'phone' + ? countryCode + forgettedPasswordInfo + : undefined, + newPassword: passValue, + confirmNewPassword: confirmPassValue, + }; + + const result = await resetPassword(apiRequest); + const jsonRes = await result.json(); + + if (jsonRes.success) { + setChangePasswordStatus('success'); + onPasswordChanged(); + } else { + setChangePasswordStatus('failed'); + setChangePassFailedMessage(jsonRes.message); + } + setChangePassAlertOpen(true); + + setChangePasswordLoading(false); + } + }; + + const isValidPassword = (value: string) => { + return ( + containsNumber(value) && + containsSymbol(value) && + least8Chars(value) && + hasUpperAndLowerLetter(value) + ); + }; + + return ( + + setChangePassAlertOpen(false)} + color={changePasswordStatus === 'failed' ? 'error' : 'success'} + > + {changePasswordStatus === 'failed' + ? changePassFailedMessage + : t('forgetPassword.passwordChangedSuccessfully')} + + + + + {t('forgetPassword.changePassword')} + + + + + + + {t('forgetPassword.createANewPassword')} + + + setPassValue(e.target.value)} + onBlur={handleBlur} + error={inputTouched && !isValidPassword(passValue)} + autoFocus + slotProps={{ + htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } }, + input: { + startAdornment: confirmPassValue && + isValidPassword(passValue) && + passValue === confirmPassValue && ( + + ), + endAdornment: passValue ? ( + setShowPassword(!showPassword)} + > + {showPassword ? : } + + ) : ( + '' + ), + }, + }} + sx={{ mt: 4 }} + /> + + {!isValidPassword(passValue) && ( + + {passwordValidationRules.map((rule) => ( + + + + {rule.title} + + ))} + + )} + + setConfirmPassValue(e.target.value)} + onBlur={handleConfirmPassBlur} + error={confirmInputTouched && confirmPassValue !== passValue} + slotProps={{ + htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } }, + input: { + startAdornment: confirmPassValue && + isValidPassword(passValue) && + passValue === confirmPassValue && ( + + ), + endAdornment: confirmPassValue ? ( + setShowConfirmPassword(!showConfirmPassword)} + > + {showConfirmPassword ? : } + + ) : ( + '' + ), + }, + }} + sx={{ my: 4 }} + /> + + + + + + ); +}; diff --git a/src/features/authorization/components/ForgetPassword/ForgetPasswordContainer.tsx b/src/features/authorization/components/ForgetPassword/ForgetPasswordContainer.tsx new file mode 100644 index 0000000..137b413 --- /dev/null +++ b/src/features/authorization/components/ForgetPassword/ForgetPasswordContainer.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import type { AuthType } from '../../types/authTypes'; +import { ForgettedPasswordInfo } from './ForgettedPasswordInfo'; +import { ForgetPasswordOtp } from './ForgetPasswordOtp'; +import { ChangePassword } from './ChangePassword'; +import type { CountryCode } from '@/types/commonTypes'; + +export const ForgetPasswordContainer = () => { + const [forgetPassCurrentStep, setForgetPassCurrentStep] = useState< + 'enterInfo' | 'verifyOtp' | 'setPassword' + >('enterInfo'); + const [forgettedPasswordInfo, setForgettedPasswordInfo] = + useState(''); + const [infoCountryCode, setInfoCountryCode] = useState('+98'); + const [infoType, setInfoType] = useState('email'); + + const handleVerifyOtp = () => { + setForgetPassCurrentStep('verifyOtp'); + }; + + const handleEditInfo = () => { + setForgetPassCurrentStep('enterInfo'); + }; + + const handleOtpVerified = () => { + setForgetPassCurrentStep('setPassword'); + }; + + const handlePasswordChanged = () => { + console.log('changingPasswordTo'); + }; + + return ( + <> + {forgetPassCurrentStep === 'enterInfo' && ( + + )} + + {forgetPassCurrentStep === 'verifyOtp' && ( + + )} + + {forgetPassCurrentStep === 'setPassword' && ( + + )} + + ); +}; diff --git a/src/features/authorization/components/ForgetPassword/ForgetPasswordOtp.tsx b/src/features/authorization/components/ForgetPassword/ForgetPasswordOtp.tsx new file mode 100644 index 0000000..021aa81 --- /dev/null +++ b/src/features/authorization/components/ForgetPassword/ForgetPasswordOtp.tsx @@ -0,0 +1,185 @@ +import { useTranslation } from 'react-i18next'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { Edit2 } from 'iconsax-react'; +import DigitInput from '@/components/components/DigitsInput'; +import type { AuthType } from '../../types/authTypes'; +import { useEffect, useState } from 'react'; +import { Toast } from '@/components/Toast'; +import { AuthenticationCard } from '../AuthenticationCard'; +import type { ConfirmForgetPassCodeRequest } from '../../types/userTypes'; +import type { CountryCode } from '@/types/commonTypes'; +import { confirmForgetPassCode } from '../../api/authorizationAPI'; + +interface ForgetPasswordOtpProps { + forgettedPasswordInfo: string; + infoType: AuthType; + countryCode: CountryCode; + onEditInfo: () => void; + onOTPVerified: (otpCode: string) => void; +} + +export function ForgetPasswordOtp({ + forgettedPasswordInfo, + infoType, + countryCode, + onEditInfo, + onOTPVerified, +}: ForgetPasswordOtpProps) { + const [otpCode, setOtpCode] = useState(''); + const [otpDigitInvalid, setOtpDigitInvalid] = useState(false); + const [verifyStatus, setVerifyStatus] = useState<'failed' | 'success'>(); + const [verifyStatusLoading, setVerifyStatusLoading] = + useState(false); + const [verifyAlertMessage, setVerifyAlertMessage] = useState(); + const { t } = useTranslation('authentication'); + const [resendTimer, setResendTimer] = useState(120); + const [canResend, setCanResend] = useState(false); + const [resendLoading, setResendLoading] = useState(false); + + useEffect(() => { + let interval: NodeJS.Timeout; + if (resendTimer > 0) { + interval = setInterval(() => { + setResendTimer((prev) => prev - 1); + }, 1000); + } else { + setCanResend(true); + } + + return () => clearInterval(interval); + }, [resendTimer]); + + const handleResendOTPCode = () => { + setResendLoading(true); + + // TODO: Call API here instead of settimeout + + setTimeout(() => { + console.log('resended'); + + setResendTimer(120); + setCanResend(false); + setResendLoading(false); + }, 1000); + }; + + const formatTime = (seconds: number) => { + const min = Math.floor(seconds / 60); + const sec = seconds % 60; + return `${min}:${sec.toString().padStart(2, '0')}`; + }; + + const handleVerifyOTP = async () => { + if (!otpCode || otpCode.length < 4) { + setOtpDigitInvalid(true); + } else { + setOtpDigitInvalid(false); + setVerifyStatusLoading(true); + + // Change setTimeout to api call + const apiRequest: ConfirmForgetPassCodeRequest = { + email: infoType === 'email' ? forgettedPasswordInfo : undefined, + phoneNumber: + infoType === 'phone' + ? countryCode + forgettedPasswordInfo + : undefined, + code: otpCode, + }; + + const result = await confirmForgetPassCode(apiRequest); + const jsonRes = await result.json(); + + if (jsonRes.success) { + setVerifyStatus('success'); + onOTPVerified(otpCode); + } else { + setVerifyStatus('failed'); + setVerifyAlertMessage(jsonRes.message); + } + + setVerifyStatusLoading(false); + } + }; + + return ( + + + setVerifyAlertMessage(undefined)} + color={'error'} + > + {verifyAlertMessage} + + + + + {t('forgetPassword.forgetPassword')} + + + + + + + {infoType === 'email' + ? t( + 'forgetPassword.anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress', + ) + : t( + 'forgetPassword.anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber', + )} + + + setOtpCode(value)} + /> + + + + + + {t('verify.resendCodeIn')} + + + + + ); +} diff --git a/src/features/authorization/components/ForgetPassword/ForgettedPasswordInfo.tsx b/src/features/authorization/components/ForgetPassword/ForgettedPasswordInfo.tsx new file mode 100644 index 0000000..6d2cf54 --- /dev/null +++ b/src/features/authorization/components/ForgetPassword/ForgettedPasswordInfo.tsx @@ -0,0 +1,164 @@ +import { Button, Stack, TextField, Typography } from '@mui/material'; +import { useRef, useState, type Dispatch } from 'react'; +import { useTranslation } from 'react-i18next'; +import { isNumeric } from '@/utils/regexes/isNumeric'; +import type { AuthType } from '../../types/authTypes'; +import { isEmail } from '@/utils/regexes/isEmail'; +import { AuthenticationCard } from '../AuthenticationCard'; +import { CountryCodeSelector } from '../CountryCodeSelector'; +import type { CountryCode } from '@/types/commonTypes'; +import { sendForgetPassCode } from '../../api/authorizationAPI'; +import type { SendForgetPassCodeRequest } from '../../types/userTypes'; +import { Toast } from '@/components/Toast'; +import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber'; + +export interface ForgettedPasswordInfoProps { + forgettedPasswordInfo: string; + setForgettedPasswordInfo: Dispatch; + infoType: AuthType; + setInfoType: Dispatch; + onVerifyOtp: (value: string) => void; + countryCode: CountryCode; + setCountryCode: Dispatch; +} + +export function ForgettedPasswordInfo({ + forgettedPasswordInfo, + setForgettedPasswordInfo, + infoType, + setInfoType, + onVerifyOtp, + countryCode, + setCountryCode, +}: ForgettedPasswordInfoProps) { + const { t } = useTranslation('authentication'); + const textFieldRef = useRef(null); + const inputRef = useRef(null); + const [error, setError] = useState(); + const [touched, setTouched] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const [sendCodeLoading, setSendCodeLoading] = useState(false); + const inputError: boolean = touched && !!error; + + const handleInputChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + setForgettedPasswordInfo(newValue); + + // If the new value contains only digits (or is empty), it's a phone number + if (isNumeric(newValue)) { + setInfoType('phone'); + } else { + setInfoType('email'); + } + }; + + const handleBlur = () => { + setTouched(true); + validateInput(forgettedPasswordInfo, infoType); + }; + + const validateInput = ( + value: string, + authType: AuthType, + setErrors: boolean = true, + ) => { + if (!value) { + if (setErrors) setError(t('loginForm.thisFieldIsRequired')); + return false; + } else if (authType === 'email' && !isEmail(value)) { + if (setErrors) setError(t('loginForm.emailIsInvalid')); + return false; + } else if (authType === 'phone' && !isPhoneNumber(countryCode, value)) { + if (setErrors) setError(t('loginForm.phoneNumberIsInvalid')); + return false; + } else { + if (setErrors) setError(undefined); + return true; + } + }; + + const handleSubmit = async () => { + if (validateInput(forgettedPasswordInfo, infoType, false)) { + setSendCodeLoading(true); + + const sendCodeRequest: SendForgetPassCodeRequest = { + email: infoType === 'email' ? forgettedPasswordInfo : undefined, + phoneNumber: + infoType === 'phone' + ? countryCode + forgettedPasswordInfo + : undefined, + }; + const result = await sendForgetPassCode(sendCodeRequest); + const jsonRes = await result.json(); + + if (!jsonRes.success) { + setErrorMessage(jsonRes.message); + } + + setSendCodeLoading(false); + onVerifyOtp(forgettedPasswordInfo); + } else { + inputRef.current?.focus(); + validateInput(forgettedPasswordInfo, infoType); + } + }; + + const showAdornment = + infoType === 'phone' && forgettedPasswordInfo.length > 0; + + return ( + + setErrorMessage(undefined)} + open={!!errorMessage} + > + {errorMessage} + + + + + {t('forgetPassword.forgetPassword')} + + + {t( + 'forgetPassword.pleaseEnterYourMobileNumberEmailToRecoverYourPassword', + )} + + + + + ), + }, + }} + sx={{ my: 4, mb: 8 }} + /> + + + + + + ); +} diff --git a/src/features/authorization/index.ts b/src/features/authorization/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/authorization/routes/AuthenticationPage.tsx b/src/features/authorization/routes/AuthenticationPage.tsx new file mode 100644 index 0000000..47917cf --- /dev/null +++ b/src/features/authorization/routes/AuthenticationPage.tsx @@ -0,0 +1,20 @@ +import { FlexBox } from '@/components/components/common/FlexBox'; +import Logo from '@/components/Logo'; +import { AuthenticationSteps } from '../components/AuthenticationSteps/AuthenticationSteps'; + +export function AuthenticationPage() { + return ( + + + + + ); +} diff --git a/src/features/authorization/routes/ForgetPasswordPage.tsx b/src/features/authorization/routes/ForgetPasswordPage.tsx new file mode 100644 index 0000000..f54d961 --- /dev/null +++ b/src/features/authorization/routes/ForgetPasswordPage.tsx @@ -0,0 +1,20 @@ +import { FlexBox } from '@/components/components/common/FlexBox'; +import Logo from '@/components/Logo'; +import { ForgetPasswordContainer } from '../components/ForgetPassword/ForgetPasswordContainer'; + +export function ForgetPasswordPage() { + return ( + + + + + ); +} diff --git a/src/features/authorization/types/authTypes.ts b/src/features/authorization/types/authTypes.ts new file mode 100644 index 0000000..91e45f5 --- /dev/null +++ b/src/features/authorization/types/authTypes.ts @@ -0,0 +1,10 @@ +export type AuthType = 'email' | 'phone'; + +export type AuthMode = 'register' | 'login'; + +export type AuthStep = + | 'emailOrPhone' + | 'verify' + | 'enterPassword' + | 'addPhoneNumber' + | 'addedPhoneNumberVerify'; diff --git a/src/features/authorization/types/userTypes.ts b/src/features/authorization/types/userTypes.ts new file mode 100644 index 0000000..99eb998 --- /dev/null +++ b/src/features/authorization/types/userTypes.ts @@ -0,0 +1,138 @@ +// GetUserStatusByPhoneNumberOrEmail + +import type { ApiResponse } from '@/types/apiResponse'; +import type { GUID } from '@/types/commonTypes'; + +export interface GetUserStatusByPhoneNumberOrEmailRequest { + phoneNumber?: string; + email?: string; +} + +export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse { + userStatus: UserStatus; +} + +export enum UserStatus { + None = 0, + RegisteredWithPassword = 1, + RegisteredWithoutPassword = 2, + NotRegistered = 3, +} + +// LoginOrSignUpWithOtp + +export interface LoginRequest { + otpCode: string; + phoneNumber?: string; + email?: string; + returnUrl: string; +} + +export interface PasswordLoginRequest { + phoneNumber?: string; + email?: string; + password: string; + returnUrl: string; +} + +export interface LoginResponse extends ApiResponse { + returnUrl: string; + userId: GUID; + registeredWithOutPhoneNumber: boolean; + completedUserInformation: boolean; +} + +// SendSmsOtp + +export interface SendSmsOtpRequest { + phoneNumber: string; +} + +// SendEmailOtp + +export interface SendEmailOtpRequest { + email: string; +} + +// ConfirmOtp + +export interface ConfirmEmailOtpRequest { + email: string; + otpCode: string; +} + +export interface ConfirmSmsOtpRequest { + phoneNumber: string; + otpCode: string; +} + +export interface ConfirmOtpResponse extends ApiResponse { + confirm: boolean; +} + +// ResetPassword + +export interface ResetPasswordRequest { + email?: string; + phoneNumber?: string; + newPassword: string; + confirmNewPassword: string; +} + +export interface ResetPasswordResponse extends ApiResponse { + passwordChanged: boolean; +} + +// SendForgetPassCode + +export interface SendForgetPassCodeRequest { + email?: string; + phoneNumber?: string; +} + +// ConfirmForgetPassCode + +export interface ConfirmForgetPassCodeRequest { + email?: string; + phoneNumber?: string; + code: string; +} + +// LoginOrSignUpWithGoogle + +export interface GoogleCodeClientResponse { + id_token: string; +} + +export interface LoginOrSignUpWithGoogleRequest { + idToken: string; + returnUrl: string; +} + +export interface LoginOrSignUpWithGoogleResponse extends ApiResponse { + userId: GUID; + registeredWithOutPhoneNumber: boolean; + completedUserInformation: boolean; + returnUrl: string; +} + +// CompleteUserInformation + +export interface CompleteUserInformationRequest { + firstName?: string; + lastName?: string; + gender?: Gender; + nationalCode?: string; + savePassword?: boolean; + password?: string; + saveEmail?: boolean; + email?: string; + birthDate?: string; + countryCode?: string; + userId?: GUID; +} + +export enum Gender { + Male = 1, + Female = 2, +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..3a11320 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,12 @@ +import React from 'react'; + +declare global { + namespace JSX { + interface Element extends React.ReactElement {} + } + + interface Window { + google: typeof google; + } + const google: any; +} diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index f25aa37..47ce02e 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -1,3 +1,4 @@ + import { useState, useEffect, useCallback } from 'react'; import { type ApiResponse } from '@/types/apiResponse'; diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index cbb855c..557ffe5 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -12,8 +12,8 @@ const apiClient = axios.create({ // Set default headers headers: { + 'Content-Type': 'application/json', Accept: 'application/json', - Authorization: `Bearer ${localStorage.getItem('authToken')}`, }, }); diff --git a/src/providers/CustomThemeProvider.tsx b/src/providers/CustomThemeProvider.tsx index 6887573..f7faed6 100644 --- a/src/providers/CustomThemeProvider.tsx +++ b/src/providers/CustomThemeProvider.tsx @@ -37,13 +37,28 @@ export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({ cssVariables: { colorSchemeSelector: 'class', }, + components: { + MuiTextField: { + defaultProps: { + variant: 'outlined', + fullWidth: true, + }, + }, + MuiButton: { + defaultProps: { + size: 'large', + fullWidth: true, + variant: 'contained', + }, + }, + }, spacing: 8, typography: typography, }); }, [i18n]); return ( - + {children} ); diff --git a/src/providers/RtlProvider.tsx b/src/providers/RtlProvider.tsx index 7bbf52a..6550bb6 100644 --- a/src/providers/RtlProvider.tsx +++ b/src/providers/RtlProvider.tsx @@ -1,25 +1,23 @@ -import React, { useState, useEffect } from 'react'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { CacheProvider } from '@emotion/react'; import createCache from '@emotion/cache'; import rtlPlugin from 'stylis-plugin-rtl'; +import { prefixer } from 'stylis'; // This provider configures Emotion's cache to support RTL. export const RtlProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { i18n } = useTranslation(); - const [cache, setCache] = useState(createCache({ key: 'css' })); - useEffect(() => { - const newDir = i18n.dir(i18n.language); - - const newCache = createCache({ - key: 'css', - stylisPlugins: newDir === 'rtl' ? [rtlPlugin] : [], + const cacheRtl = useMemo(() => { + const isRtl = i18n.dir(i18n.language) === 'rtl'; + return createCache({ + key: isRtl ? 'muirtl' : 'muiltr', + stylisPlugins: isRtl ? [prefixer, rtlPlugin] : [], }); - setCache(newCache); - }, [i18n, i18n.language]); + }, [i18n]); - return {children}; + return {children}; }; diff --git a/src/routes/config.tsx b/src/routes/config.tsx new file mode 100644 index 0000000..61f2f8d --- /dev/null +++ b/src/routes/config.tsx @@ -0,0 +1,119 @@ +import { Layout } from '@/components/Layout/Layout'; +import { + Calendar, + Devices, + LocationTick, + Mobile, + PasswordCheck, + Personalcard, + ProfileCircle, + Setting, + Shield, + Sms, + type Icon, +} from 'iconsax-react'; +import { type ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; + +export interface RouteConfig { + path: string; + element?: ReactNode; + navConfig?: { + title: string; // Translation key + icon?: Icon; + }; + children?: RouteConfig[]; +} + +// can lazy load component if needed (ex. lazy(() => import('@/features/home/routes/HomePage'));) +export const appRoutes: RouteConfig[] = [ + { + path: '/', + element: , + }, + { + path: '/setting', + element: , + children: [ + // TODO: add route component to each route + { + path: '/setting/profile', + navConfig: { + // Profile component + title: 'side.account', + icon: ProfileCircle, + }, + children: [ + { + path: '/setting/profile#info', + navConfig: { + title: 'side.personalInfo', + icon: Personalcard, + }, + }, + { + path: '/setting/profile#contact-info', + navConfig: { + title: 'side.contactInfo', + icon: Mobile, + }, + }, + { + path: '/setting/profile#email', + navConfig: { + title: 'side.email', + icon: Sms, + }, + }, + ], + }, + { + path: '/setting/security', + // security component + navConfig: { + title: 'side.security', + icon: Shield, + }, + children: [ + { + path: '/setting/security#password', + navConfig: { + title: 'side.password', + icon: PasswordCheck, + }, + }, + { + path: '/setting/security#confirmed-ips', + navConfig: { + title: 'side.confirmedIps', + icon: LocationTick, + }, + }, + { + path: '/setting/security#recent-sessions', + navConfig: { + title: 'side.recentSessions', + icon: Devices, + }, + }, + ], + }, + { + path: '/setting/active-sessions', + // active session component + navConfig: { + title: 'side.activeSessions', + icon: Calendar, + }, + }, + { + path: '/setting/preferences', + // setting component + navConfig: { + title: 'side.setting', + icon: Setting, + }, + }, + ], + }, +]; diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 0000000..541a2fb --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,34 @@ +import { Suspense, type ReactNode } from 'react'; +import { createBrowserRouter, type RouteObject } from 'react-router-dom'; +import { appRoutes, type RouteConfig } from './config'; + +/** + * A recursive function to map our custom route config to the format + * that react-router-dom expects, applying layouts and guards. + */ +function mapRoutes(routes: RouteConfig[]): RouteObject[] { + return routes.map((route) => { + // Start with the base element, wrapped in Suspense for lazy loading + let element: ReactNode = ( + Loading...}>{route.element} + ); + + // Conditionally wrap the element in the specified layout + // if (route.layout) { + // element = {element}; + // } + + // Conditionally wrap the element in the authentication guard + // if (route.authRequired) { + // element = {element}; + // } + + return { + path: route.path, + element: element, + ...(route.children && { children: mapRoutes(route.children) }), + }; + }); +} + +export const router = createBrowserRouter(mapRoutes(appRoutes)); diff --git a/src/theme/colors.ts b/src/theme/colors.ts index c3446ea..4c68db7 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -49,9 +49,9 @@ export const PALETTE: Palette = { }, error: { light: { - main: '#E53935', - dark: '#C62828', - light: '#EF5350', + main: '#d32f2f', + dark: '#c62828', + light: '#ef5350', contrastText: '#FFFFFF', }, dark: { @@ -91,9 +91,9 @@ export const PALETTE: Palette = { }, success: { light: { - main: '#43A047', - dark: '#1B5E20', - light: '#81C784', + main: '#2e7d32', + dark: '#1b5e20', + light: '#4caf50', contrastText: '#FFFFFF', }, // TODO diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts new file mode 100644 index 0000000..b6dee6b --- /dev/null +++ b/src/types/commonTypes.ts @@ -0,0 +1,3 @@ +export type GUID = `${string}-${string}-${string}-${string}-${string}`; + +export type CountryCode = `+${number}`; diff --git a/src/types/fetchPromise.ts b/src/types/fetchPromise.ts new file mode 100644 index 0000000..84483d5 --- /dev/null +++ b/src/types/fetchPromise.ts @@ -0,0 +1,5 @@ +export type FetchPromise = Promise>; + +export interface FetchResponse extends Response { + json(): Promise; +} diff --git a/src/utils/regexes/containsNumber.tsx b/src/utils/regexes/containsNumber.tsx new file mode 100644 index 0000000..5ccefc8 --- /dev/null +++ b/src/utils/regexes/containsNumber.tsx @@ -0,0 +1 @@ +export const containsNumber = (value: string) => /\d/.test(value); diff --git a/src/utils/regexes/containsSymbol.tsx b/src/utils/regexes/containsSymbol.tsx new file mode 100644 index 0000000..c3ac510 --- /dev/null +++ b/src/utils/regexes/containsSymbol.tsx @@ -0,0 +1 @@ +export const containsSymbol = (value: string) => /[!@#$%&*\^]/.test(value); diff --git a/src/utils/regexes/hasUpperAndLowerLetter.tsx b/src/utils/regexes/hasUpperAndLowerLetter.tsx new file mode 100644 index 0000000..a29c262 --- /dev/null +++ b/src/utils/regexes/hasUpperAndLowerLetter.tsx @@ -0,0 +1,5 @@ +export const hasUpperAndLowerLetter = (value: string) => { + const hasUpper = /[A-Z]/.test(value); + const hasLower = /[a-z]/.test(value); + return hasUpper && hasLower; +}; diff --git a/src/utils/regexes/isEmail.tsx b/src/utils/regexes/isEmail.tsx new file mode 100644 index 0000000..b50768d --- /dev/null +++ b/src/utils/regexes/isEmail.tsx @@ -0,0 +1,2 @@ +export const isEmail = (value: string) => + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value); diff --git a/src/utils/regexes/isNumeric.ts b/src/utils/regexes/isNumeric.ts new file mode 100644 index 0000000..10e385d --- /dev/null +++ b/src/utils/regexes/isNumeric.ts @@ -0,0 +1 @@ +export const isNumeric = (value: string) => /^\d*$/.test(value); diff --git a/src/utils/regexes/isValidPhoneNumber.tsx b/src/utils/regexes/isValidPhoneNumber.tsx new file mode 100644 index 0000000..c3d54cb --- /dev/null +++ b/src/utils/regexes/isValidPhoneNumber.tsx @@ -0,0 +1,7 @@ +import parsePhoneNumberFromString from 'libphonenumber-js'; + +export const isPhoneNumber = (code: string, phone: string) => { + const phoneNumber = parsePhoneNumberFromString(code + phone); + + return phoneNumber && phoneNumber.isValid(); +}; diff --git a/src/utils/regexes/least8Chars.tsx b/src/utils/regexes/least8Chars.tsx new file mode 100644 index 0000000..c4c4c8a --- /dev/null +++ b/src/utils/regexes/least8Chars.tsx @@ -0,0 +1 @@ +export const least8Chars = (value: string) => value.length >= 8; diff --git a/vite.config.d.ts b/vite.config.d.ts new file mode 100644 index 0000000..2c646ae --- /dev/null +++ b/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import('vite').UserConfig; +export default _default; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..e7bba8d --- /dev/null +++ b/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});