diff --git a/index.html b/index.html index 9fa3f54c..fe4355ba 100644 --- a/index.html +++ b/index.html @@ -44,6 +44,5 @@
- diff --git a/jest.config.ts b/jest.config.ts index 86803803..2f691578 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -132,10 +132,10 @@ const config: Config = { // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test - setupFiles: ['/jest.setup.ts'], + // setupFiles: ['/jest.setup.ts'], // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + setupFilesAfterEnv: ['/jest.setup.ts'], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, @@ -153,10 +153,7 @@ const config: Config = { // testLocationInResults: false, // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], + testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: [ @@ -174,8 +171,8 @@ const config: Config = { // A map from regular expressions to paths to transformers transform: { - '^.+\\.(ts|tsx)?$': 'babel-jest', '^.+\\.(js|jsx)$': 'babel-jest', + '^.+\\.tsx?$': 'babel-jest', '^.+\\.xml$': 'jest-transform-stub', }, diff --git a/jest.setup.ts b/jest.setup.ts index e9138830..00086045 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,3 +1,6 @@ +import '@testing-library/jest-dom/jest-globals'; +import '@testing-library/jest-dom'; + let originalLocalStorage: Storage; const localStorageMock: Storage = { clear() { diff --git a/package-lock.json b/package-lock.json index 6ef47ca1..2ad6fe66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "@deriv-com/analytics": "^1.5.3", "@deriv-com/api-hooks": "^1.1.1", "@deriv-com/translations": "^1.2.4", - "@deriv-com/ui": "latest", + "@deriv-com/ui": "^1.29.0", "@deriv-com/utils": "latest", "@deriv/deriv-charts": "^2.1.23", "@deriv/js-interpreter": "^3.0.0", - "@deriv/quill-icons": "latest", + "@deriv/quill-icons": "~1.22.10", "@svgr/core": "^8.1.0", "@tanstack/react-query": "^5.29.2", "blockly": "^10.4.3", @@ -42,7 +42,7 @@ "react-dom": "^18.2.0", "react-joyride": "^2.5.3", "react-loadable": "^5.5.0", - "react-router-dom": "^5.3.4", + "react-router-dom": "^6.25.1", "react-tiny-popover": "^8.0.4", "react-toastify": "^9.1.3", "react-transition-group": "^4.4.5", @@ -50,6 +50,7 @@ "redux": "^5.0.1", "redux-thunk": "^3.1.0", "ua-parser-js": "^1.0.38", + "usehooks-ts": "^3.1.0", "uuid": "^9.0.1", "yup": "^1.4.0" }, @@ -77,7 +78,9 @@ "@rsbuild/plugin-react": "^1.0.1-beta.1", "@rsbuild/plugin-sass": "^1.0.1-beta.1", "@testing-library/dom": "^10.3.1", + "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -117,7 +120,7 @@ "stylelint-formatter-pretty": "^2.1.1", "stylelint-no-unsupported-browser-features": "^4.0.0", "stylelint-selector-bem-pattern": "^4.0.0", - "ts-jest": "^29.2.2", + "ts-jest": "^29.1.2", "tsconfig-paths-webpack-plugin": "^4.1.0", "typescript": "^5.5.3", "url": "^0.11.3", @@ -129,6 +132,12 @@ "node": "18.x" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", + "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", + "dev": true + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -3001,9 +3010,9 @@ } }, "node_modules/@deriv/quill-icons": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@deriv/quill-icons/-/quill-icons-1.23.2.tgz", - "integrity": "sha512-NpbP/FyYAgDtvBCO6OD33pjHuGfYdXeY2O3rqqgVjcyY5I0THl4RyRtxw9fn+h6IRaJ6SZX6c7vTV/5qOrUfzw==", + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/@deriv/quill-icons/-/quill-icons-1.22.13.tgz", + "integrity": "sha512-m2dfn7MbQRnudgHac7n2qnGWpzfHnvDSC644ZPSZQuuY5CYn5qBr69c5fmCfYVWBaZmZRklcw55kutxmUMmcTw==", "peerDependencies": { "react": ">= 16", "react-dom": ">= 16" @@ -4435,6 +4444,14 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@remix-run/router": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", + "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.1.tgz", @@ -5282,6 +5299,124 @@ "node": ">=8" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.6.tgz", + "integrity": "sha512-8qpnGVincVDLEcQXWaHOf6zmlbwTKc6Us6PPu4CRnPXCzo2OGBS5cwgMMOWdxDpEz1mkbvXHpEy99M5Yvt682w==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "@babel/runtime": "^7.9.2", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@jest/globals": ">= 28", + "@types/bun": "latest", + "@types/jest": ">= 28", + "jest": ">= 28", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "@jest/globals": { + "optional": true + }, + "@types/bun": { + "optional": true + }, + "@types/jest": { + "optional": true + }, + "jest": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/react": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.0.tgz", @@ -5309,6 +5444,19 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -8327,6 +8475,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -11818,19 +11972,6 @@ "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" }, - "node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -16916,19 +17057,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, - "node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/path-to-regexp/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -17841,39 +17969,33 @@ } }, "node_modules/react-router": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", - "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "version": "6.25.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.1.tgz", + "integrity": "sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw==", + "dependencies": { + "@remix-run/router": "1.18.0" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=15" + "react": ">=16.8" } }, "node_modules/react-router-dom": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", - "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.3.4", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "version": "6.25.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.1.tgz", + "integrity": "sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ==", + "dependencies": { + "@remix-run/router": "1.18.0", + "react-router": "6.25.1" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=15" + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/react-tabs": { @@ -18381,11 +18503,6 @@ "node": ">=8" } }, - "node_modules/resolve-pathname": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", - "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -20935,11 +21052,6 @@ "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -21649,6 +21761,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/usehooks-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", + "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -21720,11 +21846,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/value-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", - "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" - }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", diff --git a/package.json b/package.json index 70620ffa..94e496d0 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,11 @@ "@deriv-com/analytics": "^1.5.3", "@deriv-com/api-hooks": "^1.1.1", "@deriv-com/translations": "^1.2.4", - "@deriv-com/ui": "latest", + "@deriv-com/ui": "^1.29.0", "@deriv-com/utils": "latest", "@deriv/deriv-charts": "^2.1.23", "@deriv/js-interpreter": "^3.0.0", - "@deriv/quill-icons": "latest", + "@deriv/quill-icons": "~1.22.10", "@svgr/core": "^8.1.0", "@tanstack/react-query": "^5.29.2", "blockly": "^10.4.3", @@ -51,7 +51,7 @@ "react-dom": "^18.2.0", "react-joyride": "^2.5.3", "react-loadable": "^5.5.0", - "react-router-dom": "^5.3.4", + "react-router-dom": "^6.25.1", "react-tiny-popover": "^8.0.4", "react-toastify": "^9.1.3", "react-transition-group": "^4.4.5", @@ -59,6 +59,7 @@ "redux": "^5.0.1", "redux-thunk": "^3.1.0", "ua-parser-js": "^1.0.38", + "usehooks-ts": "^3.1.0", "uuid": "^9.0.1", "yup": "^1.4.0" }, @@ -86,7 +87,9 @@ "@rsbuild/plugin-react": "^1.0.1-beta.1", "@rsbuild/plugin-sass": "^1.0.1-beta.1", "@testing-library/dom": "^10.3.1", + "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -126,7 +129,7 @@ "stylelint-formatter-pretty": "^2.1.1", "stylelint-no-unsupported-browser-features": "^4.0.0", "stylelint-selector-bem-pattern": "^4.0.0", - "ts-jest": "^29.2.2", + "ts-jest": "^29.1.2", "tsconfig-paths-webpack-plugin": "^4.1.0", "typescript": "^5.5.3", "url": "^0.11.3", diff --git a/src/components/layout/app-logo/app-logo.scss b/src/components/layout/app-logo/app-logo.scss new file mode 100644 index 00000000..581dc9db --- /dev/null +++ b/src/components/layout/app-logo/app-logo.scss @@ -0,0 +1,8 @@ +.app-header__logo { + padding-left: 1.2rem; + padding-right: 2rem; + border-right: 1px solid #f2f3f4; + height: 32px; + display: flex; + align-items: center; +} diff --git a/src/components/layout/app-logo/index.tsx b/src/components/layout/app-logo/index.tsx new file mode 100644 index 00000000..ac3b5322 --- /dev/null +++ b/src/components/layout/app-logo/index.tsx @@ -0,0 +1,17 @@ +import { DerivLogo, useDevice } from '@deriv-com/ui'; +import { URLConstants } from '@deriv-com/utils'; +import './app-logo.scss'; + +export const AppLogo = () => { + const { isDesktop } = useDevice(); + + if (!isDesktop) return null; + return ( + + ); +}; diff --git a/src/components/layout/footer/NetworkStatus.tsx b/src/components/layout/footer/NetworkStatus.tsx new file mode 100644 index 00000000..9faa1b11 --- /dev/null +++ b/src/components/layout/footer/NetworkStatus.tsx @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import clsx from 'clsx'; +import useNetworkStatus from '@/hooks/useNetworkStatus'; +// import { useTranslations } from '@deriv-com/translations'; + +const statusConfigs = { + blinking: { + className: 'app-footer__network-status-online app-footer__network-status-blinking', + tooltip: 'Connecting to server', + }, + offline: { className: 'app-footer__network-status-offline', tooltip: 'Offline' }, + online: { className: 'app-footer__network-status-online', tooltip: 'Online' }, +}; + +const NetworkStatus = () => { + // TODO complete the logic by adding the socket connctions status + const status = useNetworkStatus(); + // const { localize } = useTranslations(); + const { + className, + // tooltip + } = useMemo(() => statusConfigs[status], [status]); + + return ( + // +
+ // + ); +}; + +export default NetworkStatus; diff --git a/src/components/layout/footer/ServerTime.tsx b/src/components/layout/footer/ServerTime.tsx new file mode 100644 index 00000000..3711d94f --- /dev/null +++ b/src/components/layout/footer/ServerTime.tsx @@ -0,0 +1,31 @@ +// import { DATE_TIME_FORMAT_WITH_GMT, DATE_TIME_FORMAT_WITH_OFFSET } from '@/constants'; +import useSyncedTime from '@/hooks/useSyncedTime'; +import { + DATE_TIME_FORMAT_WITH_GMT, + // DATE_TIME_FORMAT_WITH_OFFSET, + // epochToLocal, + epochToUTC, +} from '@/utils/time'; +import { Text, useDevice } from '@deriv-com/ui'; + +const ServerTime = () => { + const time = useSyncedTime(); + const UTCFormat = epochToUTC(time, DATE_TIME_FORMAT_WITH_GMT); + // const localFormat = epochToLocal(time, DATE_TIME_FORMAT_WITH_OFFSET); + const { isDesktop } = useDevice(); + + return ( + // TODO: fix + // + {UTCFormat} + // + ); +}; + +export default ServerTime; diff --git a/src/components/layout/footer/WhatsApp.tsx b/src/components/layout/footer/WhatsApp.tsx new file mode 100644 index 00000000..70319dd9 --- /dev/null +++ b/src/components/layout/footer/WhatsApp.tsx @@ -0,0 +1,22 @@ +import { TooltipMenuIcon } from '@/components/tooltip-menu-icon'; +import { LegacyWhatsappIcon } from '@deriv/quill-icons'; +import { useTranslations } from '@deriv-com/translations'; +import { URLConstants } from '@deriv-com/utils'; + +const WhatsApp = () => { + const { localize } = useTranslations(); + + return ( + + + + ); +}; + +export default WhatsApp; diff --git a/src/components/layout/footer/footer.scss b/src/components/layout/footer/footer.scss index 4351c5f4..4e8244be 100644 --- a/src/components/layout/footer/footer.scss +++ b/src/components/layout/footer/footer.scss @@ -1,4 +1,80 @@ -.footer { - padding: 20px; - border-top: 1px solid; +.app-footer { + height: 3.6rem; + display: flex; + flex-direction: row-reverse; + padding: 0 1rem; + align-items: center; + position: fixed; + bottom: 0; + right: 0; + left: 0; + border-top: 1px solid #f2f3f4; + box-shadow: 0 1px 0 0 #f2f3f4 inset; + background: #fff; + + &__icon { + padding: 0.8rem; + + @include mobile-or-tablet-screen { + padding: 0; + } + } + + &__vertical-line { + width: 0.1rem; + height: 1.6rem; + background-color: #f2f3f4; + margin: 0 0.8rem; + } + + &__language { + display: flex; + align-items: center; + padding: 0.8rem; + + & span { + margin-left: 0.4rem; + } + } + + &__network-status { + width: 1rem; + height: 1rem; + border-radius: 100%; + + &-online { + background-color: #4bb4b3; + } + + &-offline { + background-color: #ff444f; + } + + &-blinking { + animation: blink 1s infinite alternate; + } + + @include mobile-or-tablet-screen { + margin-left: 1.2rem; + } + } + + &__endpoint { + margin-right: 2rem; + + &-text { + color: #377cfc; + text-decoration: underline; + } + } +} + +@keyframes blink { + 0% { + opacity: 1; + } + + 100% { + opacity: 0.2; + } } diff --git a/src/components/layout/footer/index.tsx b/src/components/layout/footer/index.tsx index 7faefd35..4503c2e0 100644 --- a/src/components/layout/footer/index.tsx +++ b/src/components/layout/footer/index.tsx @@ -1,5 +1,61 @@ +import useModalManager from '@/hooks/useModalManager'; +import { LANGUAGES } from '@/utils/languages'; +import { useTranslations } from '@deriv-com/translations'; +import { DesktopLanguagesModal } from '@deriv-com/ui'; +// import AccountLimits from './AccountLimits'; +// import ChangeTheme from './ChangeTheme'; +// import Deriv from './Deriv'; +// import Endpoint from './Endpoint'; +// import FullScreen from './FullScreen'; +// import HelpCentre from './HelpCentre'; +// import LanguageSettings from './LanguageSettings'; +// import Livechat from './Livechat'; +import NetworkStatus from './NetworkStatus'; +// import ResponsibleTrading from './ResponsibleTrading'; +import ServerTime from './ServerTime'; +import WhatsApp from './WhatsApp'; import './footer.scss'; -export default function Footer() { - return
Footer
; -} +const Footer = () => { + const { currentLang = 'EN', localize, switchLanguage } = useTranslations(); + const { + hideModal, + isModalOpenFor, + // showModal + } = useModalManager(); + + // const openLanguageSettingModal = () => showModal('DesktopLanguagesModal'); + + return ( +
+ {/* */} + {/* */} + {/* */} +
+ {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + +
+ +
+ + {/* */} + + {isModalOpenFor('DesktopLanguagesModal') && ( + switchLanguage(code)} + selectedLanguage={currentLang} + /> + )} +
+ ); +}; + +export default Footer; diff --git a/src/components/layout/header/HeaderConfig.tsx b/src/components/layout/header/HeaderConfig.tsx new file mode 100644 index 00000000..342d6428 --- /dev/null +++ b/src/components/layout/header/HeaderConfig.tsx @@ -0,0 +1,95 @@ +import { ReactNode } from 'react'; +import { + DerivProductDerivBotBrandLightLogoWordmarkHorizontalIcon as DerivBotLogo, + DerivProductDerivTraderBrandLightLogoWordmarkHorizontalIcon as DerivTraderLogo, + LegacyCashierIcon as CashierLogo, + LegacyHomeOldIcon as TradershubLogo, + LegacyReportsIcon as ReportsLogo, + PartnersProductBinaryBotBrandLightLogoWordmarkHorizontalIcon as BinaryBotLogo, + PartnersProductSmarttraderBrandLightLogoWordmarkIcon as SmarttraderLogo, +} from '@deriv/quill-icons'; + +export type PlatformsConfig = { + active: boolean; + buttonIcon: ReactNode; + description: string; + href: string; + icon: ReactNode; + showInEU: boolean; +}; + +export type MenuItemsConfig = { + as: 'a' | 'button'; + href: string; + icon: ReactNode; + label: string; +}; + +export type TAccount = { + balance: string; + currency: string; + icon: React.ReactNode; + isActive: boolean; + isEu: boolean; + isVirtual: boolean; + loginid: string; + token: string; + type: string; +}; + +export const platformsConfig: PlatformsConfig[] = [ + { + active: true, + buttonIcon: , + description: 'A whole new trading experience on a powerful yet easy to use platform.', + href: 'https://app.deriv.com', + icon: , + showInEU: true, + }, + { + active: false, + buttonIcon: , + description: 'Automated trading at your fingertips. No coding needed.', + href: 'https://app.deriv.com/bot', + icon: , + showInEU: false, + }, + { + active: false, + buttonIcon: , + description: 'Trade the world’s markets with our popular user-friendly platform.', + href: 'https://smarttrader.deriv.com/en/trading', + icon: , + showInEU: false, + }, + { + active: false, + buttonIcon: , + description: + 'Our classic “drag-and-drop” tool for creating trading bots, featuring pop-up trading charts, for advanced users.', + href: 'https://bot.deriv.com', + icon: , + showInEU: false, + }, +]; + +export const MenuItems: MenuItemsConfig[] = [ + { + as: 'a', + href: 'https://app.deriv.com/appstore/traders-hub', + icon: , + label: "Trader's Hub", + }, + { + as: 'a', + href: 'https://app.deriv.com/appstore/reports', + icon: , + label: 'Reports', + }, + { + as: 'a', + href: 'https://app.deriv.com/appstore/cashier', + icon: , + label: 'Cashier', + }, +]; diff --git a/src/components/layout/header/MenuItems/MenuItems.scss b/src/components/layout/header/MenuItems/MenuItems.scss new file mode 100644 index 00000000..4f246c3f --- /dev/null +++ b/src/components/layout/header/MenuItems/MenuItems.scss @@ -0,0 +1,4 @@ +.app-header__menu { + gap: 0.8rem; + padding: 1.25rem 1.25rem 1.25rem 1.65rem; +} diff --git a/src/components/layout/header/MenuItems/index.tsx b/src/components/layout/header/MenuItems/index.tsx new file mode 100644 index 00000000..6194e027 --- /dev/null +++ b/src/components/layout/header/MenuItems/index.tsx @@ -0,0 +1,31 @@ +import { useTranslations } from '@deriv-com/translations'; +import { MenuItem, Text, useDevice } from '@deriv-com/ui'; +import { MenuItems as items } from '../HeaderConfig'; +import './MenuItems.scss'; + +export const MenuItems = () => { + const { localize } = useTranslations(); + const { isDesktop } = useDevice(); + + return ( + <> + {isDesktop ? ( + items.map(({ as, href, icon, label }) => ( + + {localize(label)} + + )) + ) : ( + + {localize(items[1].label)} + + )} + + ); +}; diff --git a/src/components/layout/header/MobileMenu/BackButton.tsx b/src/components/layout/header/MobileMenu/BackButton.tsx new file mode 100644 index 00000000..d96102c5 --- /dev/null +++ b/src/components/layout/header/MobileMenu/BackButton.tsx @@ -0,0 +1,21 @@ +import { LegacyChevronLeft1pxIcon } from '@deriv/quill-icons'; +import { Text, useDevice } from '@deriv-com/ui'; + +type TBackButton = { + buttonText: string; + onClick: () => void; +}; + +export const BackButton = ({ buttonText, onClick }: TBackButton) => { + const { isDesktop } = useDevice(); + + return ( + + ); +}; diff --git a/src/components/layout/header/MobileMenu/MenuContent.tsx b/src/components/layout/header/MobileMenu/MenuContent.tsx new file mode 100644 index 00000000..8c3d5b3f --- /dev/null +++ b/src/components/layout/header/MobileMenu/MenuContent.tsx @@ -0,0 +1,65 @@ +import { MenuItem, Text, useDevice } from '@deriv-com/ui'; +import { PlatformSwitcher } from '../PlatformSwitcher'; +import { MobileMenuConfig } from './MobileMenuConfig'; + +export const MenuContent = () => { + const { isDesktop } = useDevice(); + const textSize = isDesktop ? 'sm' : 'md'; + + return ( +
+
+ +
+ +
+ {MobileMenuConfig().map((item, index) => { + const removeBorderBottom = item.find(({ removeBorderBottom }) => removeBorderBottom); + + return ( +
+ {item.map(({ LeftComponent, RightComponent, as, href, label, onClick, target }) => { + if (as === 'a') { + return ( + + } + target={target} + > + {label} + + ); + } + return ( + } + onClick={onClick} + rightComponent={RightComponent} + > + + {label} + + + ); + })} +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/layout/header/MobileMenu/MenuHeader.tsx b/src/components/layout/header/MobileMenu/MenuHeader.tsx new file mode 100644 index 00000000..2e7a42d3 --- /dev/null +++ b/src/components/layout/header/MobileMenu/MenuHeader.tsx @@ -0,0 +1,36 @@ +import { ComponentProps, useMemo } from 'react'; +import { LANGUAGES } from '@/utils/languages'; +import { useTranslations } from '@deriv-com/translations'; +import { Text, useDevice } from '@deriv-com/ui'; + +type TMenuHeader = { + hideLanguageSetting: boolean; + openLanguageSetting: ComponentProps<'button'>['onClick']; +}; + +export const MenuHeader = ({ hideLanguageSetting, openLanguageSetting }: TMenuHeader) => { + const { currentLang, localize } = useTranslations(); + const { isDesktop } = useDevice(); + + const countryIcon = useMemo( + () => LANGUAGES.find(({ code }) => code === currentLang)?.placeholderIconInMobile, + [currentLang] + ); + + return ( +
+ + {localize('Menu')} + + + {!hideLanguageSetting && ( + + )} +
+ ); +}; diff --git a/src/components/layout/header/MobileMenu/MobileMenu.tsx b/src/components/layout/header/MobileMenu/MobileMenu.tsx new file mode 100644 index 00000000..a9804366 --- /dev/null +++ b/src/components/layout/header/MobileMenu/MobileMenu.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import useModalManager from '@/hooks/useModalManager'; +import { LANGUAGES } from '@/utils/languages'; +import { useTranslations } from '@deriv-com/translations'; +import { Drawer, MobileLanguagesDrawer, useDevice } from '@deriv-com/ui'; +import NetworkStatus from './../../footer/NetworkStatus'; +import ServerTime from './../../footer/ServerTime'; +import { BackButton } from './BackButton'; +import { MenuContent } from './MenuContent'; +import { MenuHeader } from './MenuHeader'; +import { ToggleButton } from './ToggleButton'; + +const MobileMenu = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { currentLang = 'EN', localize, switchLanguage } = useTranslations(); + const { hideModal, isModalOpenFor, showModal } = useModalManager(); + const { isDesktop } = useDevice(); + + const openDrawer = () => setIsDrawerOpen(true); + const closeDrawer = () => setIsDrawerOpen(false); + + const openLanguageSetting = () => showModal('MobileLanguagesDrawer'); + const isLanguageSettingVisible = Boolean(isModalOpenFor('MobileLanguagesDrawer')); + + if (isDesktop) return null; + return ( + <> + + + + + + + + + {isLanguageSettingVisible ? ( + <> + + + + + ) : ( + + )} + + + + + + + + + ); +}; + +export default MobileMenu; diff --git a/src/components/layout/header/MobileMenu/MobileMenuConfig.tsx b/src/components/layout/header/MobileMenu/MobileMenuConfig.tsx new file mode 100644 index 00000000..96a80be8 --- /dev/null +++ b/src/components/layout/header/MobileMenu/MobileMenuConfig.tsx @@ -0,0 +1,123 @@ +import { ComponentProps, ReactNode } from 'react'; +import { ACCOUNT_LIMITS, HELP_CENTRE, RESPONSIBLE } from '@/utils/constants'; +import { + BrandDerivLogoCoralIcon, + IconTypes, + LegacyAccountLimitsIcon, + LegacyCashierIcon, + LegacyChartsIcon, + LegacyHelpCentreIcon, + LegacyHomeOldIcon, + LegacyLogout1pxIcon, + LegacyProfileSmIcon, + LegacyResponsibleTradingIcon, + LegacyWhatsappIcon, +} from '@deriv/quill-icons'; +import { useAuthData } from '@deriv-com/api-hooks'; +import { useTranslations } from '@deriv-com/translations'; +import { URLConstants } from '@deriv-com/utils'; + +export type TSubmenuSection = 'accountSettings' | 'cashier'; + +type TMenuConfig = { + LeftComponent: IconTypes; + RightComponent?: ReactNode; + as: 'a' | 'button'; + href?: string; + label: string; + onClick?: () => void; + removeBorderBottom?: boolean; + submenu?: TSubmenuSection; + target?: ComponentProps<'a'>['target']; +}[]; + +export const MobileMenuConfig = () => { + const { localize } = useTranslations(); + const { logout } = useAuthData(); + + const menuConfig: TMenuConfig[] = [ + [ + { + as: 'a', + href: URLConstants.derivComProduction, + label: localize('Deriv.com'), + LeftComponent: BrandDerivLogoCoralIcon, + }, + { + as: 'a', + href: URLConstants.derivAppProduction, + label: localize("Trader's Hub"), + LeftComponent: LegacyHomeOldIcon, + }, + { + as: 'a', + href: `${URLConstants.derivAppProduction}/dtrader`, + label: localize('Trade'), + LeftComponent: LegacyChartsIcon, + }, + { + as: 'a', + href: `${URLConstants.derivAppProduction}/account/personal-details`, + label: localize('Account Settings'), + LeftComponent: LegacyProfileSmIcon, + }, + { + as: 'a', + href: `${URLConstants.derivAppProduction}/cashier/deposit`, + label: localize('Cashier'), + LeftComponent: LegacyCashierIcon, + }, + // TODO add theme logic + // { + // as: 'button', + // label: localize('Dark theme'), + // LeftComponent: LegacyTheme1pxIcon, + // RightComponent: , + // }, + ], + [ + { + as: 'a', + href: HELP_CENTRE, + label: localize('Help center'), + LeftComponent: LegacyHelpCentreIcon, + }, + { + as: 'a', + href: ACCOUNT_LIMITS, + label: localize('Account limits'), + LeftComponent: LegacyAccountLimitsIcon, + }, + { + as: 'a', + href: RESPONSIBLE, + label: localize('Responsible trading'), + LeftComponent: LegacyResponsibleTradingIcon, + }, + { + as: 'a', + href: URLConstants.whatsApp, + label: localize('WhatsApp'), + LeftComponent: LegacyWhatsappIcon, + target: '_blank', + }, + // TODO add livechat logic + // { + // as: 'button', + // label: localize('Live chat'), + // LeftComponent: LegacyLiveChatOutlineIcon, + // }, + ], + [ + { + as: 'button', + label: localize('Log out'), + LeftComponent: LegacyLogout1pxIcon, + onClick: logout, + removeBorderBottom: true, + }, + ], + ]; + + return menuConfig; +}; diff --git a/src/components/layout/header/MobileMenu/ToggleButton.tsx b/src/components/layout/header/MobileMenu/ToggleButton.tsx new file mode 100644 index 00000000..b9fa0e61 --- /dev/null +++ b/src/components/layout/header/MobileMenu/ToggleButton.tsx @@ -0,0 +1,12 @@ +import { ComponentProps } from 'react'; +import { LegacyMenuHamburger1pxIcon } from '@deriv/quill-icons'; + +type TToggleButton = { + onClick: ComponentProps<'button'>['onClick']; +}; + +export const ToggleButton = ({ onClick }: TToggleButton) => ( + +); diff --git a/src/components/layout/header/MobileMenu/__tests__/BackButton.spec.tsx b/src/components/layout/header/MobileMenu/__tests__/BackButton.spec.tsx new file mode 100644 index 00000000..e6574e5f --- /dev/null +++ b/src/components/layout/header/MobileMenu/__tests__/BackButton.spec.tsx @@ -0,0 +1,37 @@ +import { useDevice } from '@deriv-com/ui'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BackButton } from '../BackButton'; + +const mockOnClick = jest.fn(); + +jest.mock('@deriv-com/ui', () => ({ + ...jest.requireActual('@deriv-com/ui'), + useDevice: jest.fn(() => ({ isDesktop: false })), +})); + +describe('BackButton Component', () => { + it('renders the button with the correct text', () => { + render(); + expect(screen.getByRole('button')).toBeDefined(); + }); + + it('calls the onClick handler when clicked', async () => { + render(); + await userEvent.click(screen.getByRole('button')); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('adjusts the text size for mobile devices', () => { + render(); + const textComponent = screen.getByText('Go Back'); + expect(textComponent).toHaveClass('derivs-text__size--lg'); + }); + + it('uses a smaller text size for non-mobile devices', () => { + (useDevice as jest.Mock).mockReturnValue({ isDesktop: true }); + render(); + const textComponent = screen.getByText('Go Back'); + expect(textComponent).toHaveClass('derivs-text__size--md'); + }); +}); diff --git a/src/components/layout/header/MobileMenu/__tests__/MenuContent.spec.tsx b/src/components/layout/header/MobileMenu/__tests__/MenuContent.spec.tsx new file mode 100644 index 00000000..0da4a112 --- /dev/null +++ b/src/components/layout/header/MobileMenu/__tests__/MenuContent.spec.tsx @@ -0,0 +1,105 @@ +import { useDevice } from '@deriv-com/ui'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MenuContent } from '../MenuContent'; + +const mockSettingsButtonClick = jest.fn(); + +jest.mock('@deriv-com/ui', () => ({ + ...jest.requireActual('@deriv-com/ui'), + useDevice: jest.fn(() => ({ isDesktop: false })), +})); + +jest.mock('@deriv-com/api-hooks', () => ({ + useAuthData: jest.fn().mockReturnValue({ + isAuthorized: true, + }), +})); + +jest.mock('../../PlatformSwitcher', () => ({ + PlatformSwitcher: () =>
PlatformSwitcher
, +})); + +jest.mock('../MobileMenuConfig', () => ({ + MobileMenuConfig: jest.fn(() => [ + [ + { + as: 'a', + href: '/home', + label: 'Home', + LeftComponent: () => Home Icon, + removeBorderBottom: false, + }, + ], + [ + { + as: 'button', + label: 'Settings', + LeftComponent: () => Settings Icon, + onClick: mockSettingsButtonClick, + removeBorderBottom: true, + }, + ], + ]), +})); + +describe('MenuContent Component', () => { + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + value: jest.fn(), + writable: true, + }); + }); + + it('renders PlatformSwitcher and MenuItem components correctly', () => { + render(); + expect(screen.getByText('PlatformSwitcher')).toBeInTheDocument(); + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('renders MenuItem as an anchor when `as` prop is "a"', () => { + render(); + expect(screen.getByRole('link', { name: 'Home Icon Home' })).toBeInTheDocument(); + }); + + it('renders anchor props correctly', () => { + render(); + const link = screen.getByRole('link', { name: 'Home Icon Home' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/home'); + expect(screen.getByText('Home Icon')).toBeInTheDocument(); + }); + + it('renders MenuItem as a button when `as` prop is "button"', () => { + render(); + expect(screen.getByRole('button', { name: 'Settings Icon Settings' })).toBeInTheDocument(); + }); + + it('renders button props correctly', async () => { + render(); + const settingsButton = screen.getByRole('button', { name: 'Settings Icon Settings' }); + expect(settingsButton).toBeInTheDocument(); + await userEvent.click(settingsButton); + expect(mockSettingsButtonClick).toHaveBeenCalled(); + }); + + it('adjusts text size for mobile devices', () => { + render(); + const text = screen.getByText('Home'); + expect(text).toHaveClass('derivs-text__size--md'); + }); + + it('adjusts text size for desktop devices', () => { + (useDevice as jest.Mock).mockReturnValue({ isDesktop: true }); + render(); + const text = screen.getByText('Home'); + expect(text).toHaveClass('derivs-text__size--sm'); + }); + + it('applies conditional border styles based on configuration', () => { + render(); + expect(screen.getAllByTestId('dt_menu_item')[0]).toHaveClass('border-b'); + expect(screen.getAllByTestId('dt_menu_item')[1]).not.toHaveClass('border-b'); + }); +}); diff --git a/src/components/layout/header/MobileMenu/__tests__/MenuHeader.spec.tsx b/src/components/layout/header/MobileMenu/__tests__/MenuHeader.spec.tsx new file mode 100644 index 00000000..6bb74d85 --- /dev/null +++ b/src/components/layout/header/MobileMenu/__tests__/MenuHeader.spec.tsx @@ -0,0 +1,52 @@ +import { useTranslations } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MenuHeader } from '../MenuHeader'; + +const mockOpenLanguageSetting = jest.fn(); + +jest.mock('@deriv-com/ui', () => ({ + ...jest.requireActual('@deriv-com/ui'), + useDevice: jest.fn(() => ({ isDesktop: false })), +})); + +jest.mock('@deriv-com/translations', () => ({ + useTranslations: jest.fn(), +})); + +describe('MenuHeader component', () => { + beforeEach(() => { + (useTranslations as jest.Mock).mockReturnValue({ + currentLang: 'EN', + localize: jest.fn(text => text), + }); + }); + + it('renders "Menu" with "lg" text size in mobile view', () => { + render(); + expect(screen.getByText('Menu')).toHaveClass('derivs-text__size--lg'); + }); + + it('renders "Menu" with "md" text size in desktop view', () => { + (useDevice as jest.Mock).mockReturnValue({ isDesktop: true }); + render(); + expect(screen.getByText('Menu')).toHaveClass('derivs-text__size--md'); + }); + + it('does not render language setting button when hideLanguageSetting is true', () => { + render(); + expect(screen.queryByText('EN')).not.toBeInTheDocument(); + }); + + it('renders language setting button with correct content when hideLanguageSetting is false', () => { + render(); + expect(screen.getByText('EN')).toBeInTheDocument(); + }); + + it('calls openLanguageSetting when language button is clicked', async () => { + render(); + await userEvent.click(screen.getByText('EN')); + expect(mockOpenLanguageSetting).toHaveBeenCalled(); + }); +}); diff --git a/src/components/layout/header/MobileMenu/__tests__/MobileMenu.spec.tsx b/src/components/layout/header/MobileMenu/__tests__/MobileMenu.spec.tsx new file mode 100644 index 00000000..e248390e --- /dev/null +++ b/src/components/layout/header/MobileMenu/__tests__/MobileMenu.spec.tsx @@ -0,0 +1,77 @@ +import { BrowserRouter } from 'react-router-dom'; +import useModalManager from '@/hooks/useModalManager'; +import { useDevice } from '@deriv-com/ui'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MobileMenu from '../MobileMenu'; + +jest.mock('@/hooks/useModalManager', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue({ + hideModal: jest.fn(), + isModalOpenFor: jest.fn().mockReturnValue(false), + showModal: jest.fn(), + }), +})); +jest.mock('@/hooks/useNetworkStatus', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue('online'), +})); +jest.mock('@/hooks/useSyncedTime', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@deriv-com/ui', () => ({ + ...jest.requireActual('@deriv-com/ui'), + useDevice: jest.fn(), +})); + +jest.mock('@deriv-com/api-hooks', () => ({ + useAuthData: jest.fn().mockReturnValue({ + isAuthorized: true, + }), +})); + +jest.mock('@deriv-com/translations', () => ({ + useTranslations: jest.fn().mockReturnValue({ + currentLang: 'EN', + localize: jest.fn(text => text), + }), +})); + +const MobileMenuComponent = () => ( + + + +); + +describe('MobileMenu component', () => { + it('should not render when isDesktop is true', () => { + (useDevice as jest.Mock).mockReturnValue({ isDesktop: true }); + render(); + expect(screen.queryByText('Menu')).not.toBeInTheDocument(); + }); + + it('should render toggle button and handle click', async () => { + (useDevice as jest.Mock).mockReturnValue({ isDesktop: false }); + render(); + expect(screen.queryByText('Menu')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button')); + expect(screen.getByText('Menu')).toBeInTheDocument(); + }); + + it('should open the language settings', async () => { + (useDevice as jest.Mock).mockReturnValue({ isDesktop: false }); + const { isModalOpenFor, showModal } = useModalManager(); + + render(); + await userEvent.click(screen.getByRole('button')); + expect(screen.getByText('EN')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('EN')); + + expect(showModal).toHaveBeenCalledWith('MobileLanguagesDrawer'); + expect(isModalOpenFor).toHaveBeenCalledWith('MobileLanguagesDrawer'); + }); +}); diff --git a/src/components/layout/header/MobileMenu/__tests__/ToggleButton.spec.tsx b/src/components/layout/header/MobileMenu/__tests__/ToggleButton.spec.tsx new file mode 100644 index 00000000..1a663076 --- /dev/null +++ b/src/components/layout/header/MobileMenu/__tests__/ToggleButton.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ToggleButton } from '../ToggleButton'; + +const mockOnClick = jest.fn(); + +describe('ToggleButton component', () => { + it('renders correctly', () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + }); + + it('calls onClick when clicked', async () => { + render(); + await userEvent.click(screen.getByRole('button')); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/layout/header/MobileMenu/index.ts b/src/components/layout/header/MobileMenu/index.ts new file mode 100644 index 00000000..f26a61cd --- /dev/null +++ b/src/components/layout/header/MobileMenu/index.ts @@ -0,0 +1 @@ +export { default as MobileMenu } from './MobileMenu'; diff --git a/src/components/layout/header/PlatformSwitcher/index.tsx b/src/components/layout/header/PlatformSwitcher/index.tsx new file mode 100644 index 00000000..40ccf3a1 --- /dev/null +++ b/src/components/layout/header/PlatformSwitcher/index.tsx @@ -0,0 +1,30 @@ +import { useTranslations } from '@deriv-com/translations'; +import { PlatformSwitcher as UIPlatformSwitcher, PlatformSwitcherItem } from '@deriv-com/ui'; +import { platformsConfig } from '../HeaderConfig'; + +export const PlatformSwitcher = () => { + const { localize } = useTranslations(); + + return ( + + {platformsConfig.map(({ active, description, href, icon }) => ( + + ))} + + ); +}; diff --git a/src/components/layout/header/SkeletonLoader/index.tsx b/src/components/layout/header/SkeletonLoader/index.tsx new file mode 100644 index 00000000..eacb7b18 --- /dev/null +++ b/src/components/layout/header/SkeletonLoader/index.tsx @@ -0,0 +1,41 @@ +import ContentLoader from 'react-content-loader'; + +type TAccountsInfoLoaderProps = { + isLoggedIn: boolean; + isMobile: boolean; + speed: number; +}; + +const LoggedInPreloader = ({ isMobile }: Pick) => ( + <> + {isMobile ? ( + <> + + + + + ) : ( + <> + + + + + + + + )} + +); + +export const AccountsInfoLoader = ({ isMobile, speed }: TAccountsInfoLoaderProps) => ( + + + +); diff --git a/src/components/layout/header/index.tsx b/src/components/layout/header/index.tsx index 1ad3fe68..6562dee0 100644 --- a/src/components/layout/header/index.tsx +++ b/src/components/layout/header/index.tsx @@ -1,34 +1,78 @@ +import { useActiveAccount } from '@/hooks/api/account'; +import { StandaloneCircleUserRegularIcon } from '@deriv/quill-icons'; import { useAuthData } from '@deriv-com/api-hooks'; -import { Button } from '@deriv-com/ui'; +import { useTranslations } from '@deriv-com/translations'; +import { Button, Header, Text, useDevice, Wrapper } from '@deriv-com/ui'; import { URLUtils } from '@deriv-com/utils'; +import { AppLogo } from '../app-logo'; +import { MenuItems } from './MenuItems'; +import { MobileMenu } from './MobileMenu'; +import { PlatformSwitcher } from './PlatformSwitcher'; +// import { AccountsInfoLoader } from './SkeletonLoader'; import './header.scss'; -export const Header = () => { - const { isAuthorized, activeLoginid, logout } = useAuthData(); +const AppHeader = () => { + const { isDesktop } = useDevice(); + const { activeLoginid, logout, isAuthorized } = useAuthData(); + const { data: activeAccount } = useActiveAccount(); + const { localize } = useTranslations(); const { getOauthURL } = URLUtils; - return ( -
-
- {!(isAuthorized || activeLoginid) ? ( -
- -
- ) : ( - + ); + } + + if (activeLoginid) { + return ( + <> + {/* */} + {isDesktop && ( + // + + // + )} + {/* */} + {/* */} + - )} -
-
+ + ); + } + }; + + return ( +
+ + + + {isDesktop && } + {isDesktop && } + + {renderAccountSection()} +
); }; + +export default AppHeader; diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index 1d2d8752..3fb47b79 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import clsx from 'clsx'; import { useDevice } from '@deriv-com/ui'; import Footer from './footer'; -import { Header } from './header'; +import AppHeader from './header'; import Body from './main-body'; import './layout.scss'; @@ -15,7 +15,7 @@ const Layout: React.FC = ({ children }) => { return (
-
+ {children}
diff --git a/src/components/shared/helpers/context/poi-context.tsx b/src/components/shared/helpers/context/poi-context.tsx deleted file mode 100644 index 3d5eead1..00000000 --- a/src/components/shared/helpers/context/poi-context.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { useLocation } from 'react-router-dom'; -import { ResidenceList } from '@deriv/api-types'; - -const submission_status_code = { - selecting: 'selecting', - submitting: 'submitting', - complete: 'complete', -} as const; - -const service_code = { - idv: 'idv', - onfido: 'onfido', - manual: 'manual', -} as const; - -type TSubmissionStatus = keyof typeof submission_status_code; -type TSubmissionService = keyof typeof service_code; - -type TPOIContext = { - submission_status: TSubmissionStatus; - setSubmissionStatus: React.Dispatch>; - submission_service: TSubmissionService; - setSubmissionService: React.Dispatch>; - selected_country: ResidenceList[number]; - setSelectedCountry: React.Dispatch>; -}; - -export const POIContextInitialState: TPOIContext = { - submission_status: submission_status_code.selecting, - setSubmissionStatus: () => submission_status_code.selecting, - submission_service: service_code.idv, - setSubmissionService: () => service_code.idv, - selected_country: {}, - setSelectedCountry: () => ({}), -}; - -export const POIContext = React.createContext(POIContextInitialState); - -export const POIProvider = ({ children }: React.PropsWithChildren) => { - const [submission_status, setSubmissionStatus] = React.useState( - submission_status_code.selecting - ); - const [submission_service, setSubmissionService] = React.useState(service_code.idv); - const [selected_country, setSelectedCountry] = React.useState({}); - const location = useLocation(); - - const state = React.useMemo( - () => ({ - submission_status, - setSubmissionStatus, - submission_service, - setSubmissionService, - selected_country, - setSelectedCountry, - }), - [selected_country, submission_service, submission_status] - ); - - React.useEffect(() => { - setSubmissionStatus(submission_status_code.selecting); - setSubmissionService(service_code.idv); - setSelectedCountry({}); - }, [location.pathname]); - - return {children}; -}; diff --git a/src/components/shared/helpers/index.ts b/src/components/shared/helpers/index.ts deleted file mode 100644 index 009efb2c..00000000 --- a/src/components/shared/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './context/poi-context'; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 7110bfc5..46a4aa6d 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -1,4 +1,3 @@ -export * from './helpers'; export * from './services'; export * from './services'; export * from './utils/array'; diff --git a/src/components/shared/styles/constants.scss b/src/components/shared/styles/constants.scss index 39e9277e..035748e3 100644 --- a/src/components/shared/styles/constants.scss +++ b/src/components/shared/styles/constants.scss @@ -236,3 +236,7 @@ $wallet-box-shadow: $btn-shadow: 0 24px 24px 0 rgb(0 0 0 / 8%), 0 0 24px 0 rgb(0 0 0 / 8%); +$tablet-width: 768px; +$desktop-width: 1024px; +$min-desktop-width: 1280px; +$max-mobile-width: 767px; diff --git a/src/components/shared/styles/mixins.scss b/src/components/shared/styles/mixins.scss index 18b9f5cc..040d7b14 100644 --- a/src/components/shared/styles/mixins.scss +++ b/src/components/shared/styles/mixins.scss @@ -307,3 +307,9 @@ @content; } } + +@mixin mobile-or-tablet-screen { + @media (max-width: #{$min-desktop-width - 1}) { + @content; + } +} diff --git a/src/components/shared_ui/autocomplete/autocomplete.tsx b/src/components/shared_ui/autocomplete/autocomplete.tsx index ea914f44..c11b4dfe 100644 --- a/src/components/shared_ui/autocomplete/autocomplete.tsx +++ b/src/components/shared_ui/autocomplete/autocomplete.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { getSearchNotFoundOption } from '@/components/shared/utils/constants'; import { getPosition } from '@/components/shared/utils/dom'; import { getEnglishCharacters, matchStringByChar } from '@/components/shared/utils/string'; -import { useBlockScroll } from '@/hooks/use-blockscroll'; +import { useBlockScroll } from '@/hooks/useBlockscroll'; import { Icon } from '@/utils/tmp/dummy'; import DropdownList, { TItem } from '../dropdown-list'; import Input from '../input'; diff --git a/src/components/shared_ui/data-list/data-list-row.tsx b/src/components/shared_ui/data-list/data-list-row.tsx index e3f30c61..795ea474 100644 --- a/src/components/shared_ui/data-list/data-list-row.tsx +++ b/src/components/shared_ui/data-list/data-list-row.tsx @@ -2,7 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import { NavLink } from 'react-router-dom'; import { clickAndKeyEventHandler, useIsMounted } from '@/components/shared'; -import { useDebounce } from '@/hooks/use-debounce'; +import { useDebounce } from '@/hooks/useDebounce'; import { TSource } from '../data-table/table-row'; import { TPassThrough, TRow } from '../types/common.types'; import { TColIndex, TDataListCell } from './data-list-cell'; diff --git a/src/components/shared_ui/modal/modal.scss b/src/components/shared_ui/modal/modal.scss index 1b93cd36..3e8c89f5 100644 --- a/src/components/shared_ui/modal/modal.scss +++ b/src/components/shared_ui/modal/modal.scss @@ -1,7 +1,3 @@ -/** Need to add new breakpoints for mixins */ -$max-mobile-width: 600px; -$min-desktop-width: 1280px; - @mixin mobile-screen { @media (max-width: #{$max-mobile-width}) { @content; @@ -20,12 +16,6 @@ $min-desktop-width: 1280px; } } -@mixin mobile-or-tablet-screen { - @media (max-width: #{$min-desktop-width - 1}) { - @content; - } -} - @mixin tablet-or-desktop-screen { @media (min-width: #{$max-mobile-width + 1}) { @content; @@ -40,7 +30,9 @@ $min-desktop-width: 1280px; position: relative; overflow: hidden; border-radius: 8px; - transition: transform 0.25s cubic-bezier(0.25, 0.1, 0.1, 0.25), opacity 0.25s cubic-bezier(0.25, 0.1, 0.1, 0.25); + transition: + transform 0.25s cubic-bezier(0.25, 0.1, 0.1, 0.25), + opacity 0.25s cubic-bezier(0.25, 0.1, 0.1, 0.25); background-color: #fff; box-shadow: 0 4px 6px 0 var(--shadow-menu); @@ -201,13 +193,12 @@ $min-desktop-width: 1280px; margin: 0.8rem; } } - + &--is-title-centered { justify-content: flex-end; position: relative; .dc-modal-header__title { - @include mobile { position: absolute; } @@ -215,24 +206,23 @@ $min-desktop-width: 1280px; justify-content: center; width: 100%; } - + .dc-modal-header__close { z-index: 1; } } + // stylelint-disable media-feature-range-notation // fix for safari bug with header being truncated @media not all and (min-resolution: 0.001dpcm) { - // stylelint-disable-line @supports (-webkit-appearance: none) { /* postcss-bem-linter: ignore */ min-height: 4.8rem; } - } } - + /** @define dc-modal-body */ &-body { &:first-child { @@ -254,6 +244,7 @@ $min-desktop-width: 1280px; padding: 0.8rem 2.4rem; } } + /** @define dc-modal-footer; weak */ &-footer { display: flex; @@ -268,7 +259,7 @@ $min-desktop-width: 1280px; margin: 0; } } - + &--separator { border-top: 2px solid var(--general-section-1); } diff --git a/src/components/shared_ui/popover/popover.tsx b/src/components/shared_ui/popover/popover.tsx index c5f5d36c..f2611abc 100644 --- a/src/components/shared_ui/popover/popover.tsx +++ b/src/components/shared_ui/popover/popover.tsx @@ -1,7 +1,7 @@ import React, { RefObject } from 'react'; import classNames from 'classnames'; import { ArrowContainer, Popover as TinyPopover } from 'react-tiny-popover'; -import { useHover, useHoverCallback } from '@/hooks/use-hover'; +import { useHover, useHoverCallback } from '@/hooks/useHover'; import { Icon } from '@/utils/tmp/dummy'; import { Text, useDevice } from '@deriv-com/ui'; import { TPopoverProps } from '../types'; diff --git a/src/components/shared_ui/tabs/tabs.tsx b/src/components/shared_ui/tabs/tabs.tsx index 22238823..c9af1cfc 100644 --- a/src/components/shared_ui/tabs/tabs.tsx +++ b/src/components/shared_ui/tabs/tabs.tsx @@ -1,7 +1,6 @@ import React from 'react'; import classNames from 'classnames'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { useConstructor } from '@/hooks/use-constructor'; +import { useConstructor } from '@/hooks/useConstructor'; import ThemedScrollbars from '../themed-scrollbars/themed-scrollbars'; import Tab from './tab'; import './tabs.scss'; @@ -14,7 +13,7 @@ declare module 'react' { } } -type TTabsProps = RouteComponentProps & { +type TTabsProps = { active_icon_color?: string; active_index?: number; background_color?: string; @@ -234,4 +233,4 @@ const Tabs = ({ ); }; -export default withRouter(Tabs); +export default Tabs; diff --git a/src/components/shared_ui/themed-scrollbars/themed-scrollbars.tsx b/src/components/shared_ui/themed-scrollbars/themed-scrollbars.tsx index bd2bef74..f0d3f571 100644 --- a/src/components/shared_ui/themed-scrollbars/themed-scrollbars.tsx +++ b/src/components/shared_ui/themed-scrollbars/themed-scrollbars.tsx @@ -1,6 +1,6 @@ import React, { RefObject, UIEventHandler } from 'react'; import classNames from 'classnames'; -import { useHover } from '@/hooks/use-hover'; +import { useHover } from '@/hooks/useHover'; type TThemedScrollbars = { autohide?: boolean; diff --git a/src/components/shared_ui/tooltip/tooltip.tsx b/src/components/shared_ui/tooltip/tooltip.tsx index ac898ffe..9e66e60e 100644 --- a/src/components/shared_ui/tooltip/tooltip.tsx +++ b/src/components/shared_ui/tooltip/tooltip.tsx @@ -1,6 +1,6 @@ import React from 'react'; import classNames from 'classnames'; -import { useHover } from '@/hooks/use-hover'; +import { useHover } from '@/hooks/useHover'; import { Icon } from '@/utils/tmp/dummy'; type TTooltip = { diff --git a/src/components/tooltip-menu-icon/TooltipMenuIcon.scss b/src/components/tooltip-menu-icon/TooltipMenuIcon.scss new file mode 100644 index 00000000..94c03f33 --- /dev/null +++ b/src/components/tooltip-menu-icon/TooltipMenuIcon.scss @@ -0,0 +1,5 @@ +.tooltip-menu-icon { + &:hover { + background: #e6e9e9; + } +} diff --git a/src/components/tooltip-menu-icon/TooltipMenuIcon.tsx b/src/components/tooltip-menu-icon/TooltipMenuIcon.tsx new file mode 100644 index 00000000..006d7fad --- /dev/null +++ b/src/components/tooltip-menu-icon/TooltipMenuIcon.tsx @@ -0,0 +1,35 @@ +import { ComponentProps, ElementType, PropsWithChildren } from 'react'; +import clsx from 'clsx'; +import { Tooltip } from '@deriv-com/ui'; +import './TooltipMenuIcon.scss'; + +type AsElement = 'a' | 'button' | 'div'; +type TTooltipMenuIcon = ComponentProps & { + as: T; + disableHover?: boolean; + tooltipContent: string; + // tooltipPosition?: TooltipProps['placement']; +}; + +// TODO replace this with deriv/ui +const TooltipMenuIcon = ({ + as, + children, + className, + disableHover = false, + // tooltipContent, + // tooltipPosition = 'top', + ...rest +}: PropsWithChildren>) => { + const Tag = as as ElementType; + + return ( + + + {children} + + + ); +}; + +export default TooltipMenuIcon; diff --git a/src/components/tooltip-menu-icon/__test__/TooltipMenuIcon.spec.tsx b/src/components/tooltip-menu-icon/__test__/TooltipMenuIcon.spec.tsx new file mode 100644 index 00000000..87f2b0bf --- /dev/null +++ b/src/components/tooltip-menu-icon/__test__/TooltipMenuIcon.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +// import userEvent from '@testing-library/user-event'; +import { TooltipMenuIcon } from '..'; + +describe('TooltipMenuIcon Component', () => { + // it('renders correctly with default props', () => { + // render( + // + // Hover me + // + // ); + // expect(screen.getByRole('button')).toHaveTextContent('Hover me'); + // }); + + // it('displays tooltip on hover', async () => { + // render( + // + // Hover me + // + // ); + // await userEvent.hover(screen.getByRole('button')); + // expect(screen.getByText('Tooltip text')).toBeInTheDocument(); + // }); + + // it('accepts and applies custom tooltip position', async () => { + // render( + // + // Hover me + // + // ); + // await userEvent.hover(screen.getByRole('button')); + // expect(screen.getByText('Tooltip text')).toBeInTheDocument(); + // }); + + it("renders correctly with as='a'", () => { + render( + + Hover me + + ); + expect(screen.getByRole('link')).toHaveTextContent('Hover me'); + }); +}); diff --git a/src/components/tooltip-menu-icon/index.ts b/src/components/tooltip-menu-icon/index.ts new file mode 100644 index 00000000..6cb8e0f8 --- /dev/null +++ b/src/components/tooltip-menu-icon/index.ts @@ -0,0 +1 @@ +export { default as TooltipMenuIcon } from './TooltipMenuIcon'; diff --git a/src/hooks/api/account/index.ts b/src/hooks/api/account/index.ts new file mode 100644 index 00000000..6a8e335b --- /dev/null +++ b/src/hooks/api/account/index.ts @@ -0,0 +1,4 @@ +export { default as useActiveAccount } from './useActiveAccount'; +export { default as useAuthentication } from './useAuthentication'; +export { default as useBalance } from './useBalance'; +export { default as useServerTime } from './useServerTime'; diff --git a/src/hooks/api/account/useActiveAccount.ts b/src/hooks/api/account/useActiveAccount.ts new file mode 100644 index 00000000..39f64d19 --- /dev/null +++ b/src/hooks/api/account/useActiveAccount.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { useBalance } from '@/hooks/api/account'; +import { useAccountList, useAuthData } from '@deriv-com/api-hooks'; + +/** A custom hook that returns the account object for the current active account. */ +const useActiveAccount = () => { + const { data, ...rest } = useAccountList(); + const { activeLoginid } = useAuthData(); + const { data: balanceData } = useBalance(); + const activeAccount = useMemo( + () => data?.find(account => account.loginid === activeLoginid), + [activeLoginid, data] + ); + + const modifiedAccount = useMemo(() => { + return activeAccount + ? { + ...activeAccount, + balance: balanceData?.accounts?.[activeAccount?.loginid]?.balance ?? 0, + } + : undefined; + }, [activeAccount, balanceData]); + + return { + /** User's current active account. */ + data: modifiedAccount, + ...rest, + }; +}; + +export default useActiveAccount; diff --git a/src/hooks/api/account/useAuthentication.ts b/src/hooks/api/account/useAuthentication.ts new file mode 100644 index 00000000..c1949c84 --- /dev/null +++ b/src/hooks/api/account/useAuthentication.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { useGetAccountStatus } from '@deriv-com/api-hooks'; + +/** A custom hook to get the verification status (basically any poi, poa, poinc, poo) of the current user. */ +const useAuthentication = () => { + const { data: get_account_status_data, ...rest } = useGetAccountStatus(); + + const modified_account_status = useMemo(() => { + if (!get_account_status_data) return; + + const needs_verification = new Set(get_account_status_data.authentication?.needs_verification); + const account_status = new Set(get_account_status_data?.status); + + return { + ...get_account_status_data.authentication, + /** client has attempted POA before */ + has_poa_been_attempted: get_account_status_data?.authentication?.document?.status !== 'none', + /** client has attempted POI before */ + has_poi_been_attempted: get_account_status_data?.authentication?.identity?.status !== 'none', + /** client has been age-verified */ + is_age_verified: account_status.has('age_verification'), + /** client is allowed to perform POI and POA (allow uploading documents) */ + is_allow_document_upload: account_status.has('allow_document_upload'), + /** client has been authenticated with IDV photo ID feature */ + is_authenticated_with_idv_photoid: account_status.has('is_authenticated_with_idv_photoid'), + /** client is prevented from verifying from idv */ + is_idv_disallowed: account_status.has('idv_disallowed'), + /** client IDV is revoked */ + is_idv_revoked: account_status.has('idv_revoked'), + /** client's name in POA documents does not match */ + is_poa_address_mismatch: account_status.has('poa_address_mismatch'), + /** client is required to verify their document (proof of address) */ + is_poa_needed: needs_verification.has('document'), + /** client can resubmit POA documents */ + is_poa_resubmission_allowed: account_status.has('allow_poa_resubmission'), + /** client's name in POI documents does not match */ + is_poi_name_mismatch: account_status.has('poi_name_mismatch'), + /** client is required to verify their identity */ + is_poi_needed: needs_verification.has('identity'), + /** client can resubmit POI documents */ + is_poi_resubmission_allowed: account_status.has('allow_poi_resubmission'), + /** client's poa verification status */ + poa_status: get_account_status_data?.authentication?.document?.status, + /** client's poi verification status */ + poi_status: get_account_status_data?.authentication?.identity?.status, + /** client's risk classification: `low`, `standard`, `high`. */ + risk_classification: get_account_status_data?.risk_classification, + }; + }, [get_account_status_data]); + + return { + data: modified_account_status, + ...rest, + }; +}; + +export default useAuthentication; diff --git a/src/hooks/api/account/useBalance.ts b/src/hooks/api/account/useBalance.ts new file mode 100644 index 00000000..df4c4168 --- /dev/null +++ b/src/hooks/api/account/useBalance.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; +import { useBalance as useAPIBalance } from '@deriv-com/api-hooks'; + +const useBalance = () => { + const { data, ...rest } = useAPIBalance({ + payload: { account: 'all' }, + }); + + const modifiedBalance = useMemo(() => ({ ...data }), [data]); + + return { data: modifiedBalance, ...rest }; +}; + +export default useBalance; diff --git a/src/hooks/api/account/useSendbirdServiceToken.ts b/src/hooks/api/account/useSendbirdServiceToken.ts new file mode 100644 index 00000000..39b19936 --- /dev/null +++ b/src/hooks/api/account/useSendbirdServiceToken.ts @@ -0,0 +1,23 @@ +import { useGetSettings, useServiceToken } from '@deriv-com/api-hooks'; + +const SEVEN_DAYS_MILLISECONDS = 604800000; + +/** A custom hook that get Service Token for Sendbird. */ +const useSendbirdServiceToken = () => { + const { isSuccess } = useGetSettings(); + const { data, ...rest } = useServiceToken({ + enabled: isSuccess, + payload: { + service: 'sendbird', + }, + staleTime: SEVEN_DAYS_MILLISECONDS, // Sendbird tokens expire 7 days by default + }); + + return { + /** return the sendbird service token */ + data: data?.sendbird, + ...rest, + }; +}; + +export default useSendbirdServiceToken; diff --git a/src/hooks/api/account/useServerTime.ts b/src/hooks/api/account/useServerTime.ts new file mode 100644 index 00000000..90e53f95 --- /dev/null +++ b/src/hooks/api/account/useServerTime.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { toMoment } from '@/components/shared'; +import { useTime } from '@deriv-com/api-hooks'; +/** + * Hook that returns the current server time fetched using `time` endpoint + */ +const useServerTime = () => { + const { data, ...rest } = useTime(); + + const modified_data = useMemo(() => { + if (!data) return; + + const server_time_moment = toMoment(data); + return { + /** Returns the server time in an instance of Moment */ + server_time_moment, + /** Returns the server time in UTC format */ + server_time_utc: server_time_moment.utc().valueOf(), + }; + }, [data]); + + return { + data: modified_data, + ...rest, + }; +}; + +export default useServerTime; diff --git a/src/hooks/index.ts b/src/hooks/index.ts deleted file mode 100644 index d2f32896..00000000 --- a/src/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useComponentVisibility'; diff --git a/src/hooks/use-blockscroll.ts b/src/hooks/useBlockscroll.ts similarity index 100% rename from src/hooks/use-blockscroll.ts rename to src/hooks/useBlockscroll.ts diff --git a/src/hooks/use-constructor.ts b/src/hooks/useConstructor.ts similarity index 100% rename from src/hooks/use-constructor.ts rename to src/hooks/useConstructor.ts diff --git a/src/hooks/use-debounce.ts b/src/hooks/useDebounce.ts similarity index 100% rename from src/hooks/use-debounce.ts rename to src/hooks/useDebounce.ts diff --git a/src/hooks/use-hover.ts b/src/hooks/useHover.ts similarity index 100% rename from src/hooks/use-hover.ts rename to src/hooks/useHover.ts diff --git a/src/hooks/useModalManager.ts b/src/hooks/useModalManager.ts new file mode 100644 index 00000000..8cc451f1 --- /dev/null +++ b/src/hooks/useModalManager.ts @@ -0,0 +1,176 @@ +import { useEffect } from 'react'; +import { useEventListener, useMap } from 'usehooks-ts'; +import { useDevice } from '@deriv-com/ui'; +import useQueryString from './useQueryString'; + +type TUseModalManagerConfig = { + shouldReinitializeModals?: boolean; +}; + +type TShowModalOptions = { + shouldClearPreviousModals?: boolean; + shouldStackModals?: boolean; +}; + +type THideModalOptions = { + shouldHideAllModals?: boolean; + shouldHidePreviousModals?: boolean; +}; + +const MODAL_QUERY_SEPARATOR = ','; + +/** + * Hook to manage states for showing/hiding multiple modals + * Use this hook when you are managing more than 1 modal to show/hide + * + * @example + * ``` + * const {isModalOpenFor, showModal} = useModalManager() + * + * return ( + * <> + * + * + * + * + * ) + * ``` + */ +export default function useModalManager(config?: TUseModalManagerConfig) { + const { deleteQueryString, queryString, setQueryString } = useQueryString(); + const { isDesktop } = useDevice(); + + const [isModalOpenScopes, actions] = useMap(); + + const syncModalParams = () => { + if (!queryString.modal) actions.setAll([]); + + if (config?.shouldReinitializeModals !== undefined && config.shouldReinitializeModals === false) { + deleteQueryString('modal'); + } else { + // sync modal query string in the URL with the initial modal open scopes + const modalHash = queryString.modal; + if (modalHash) { + const modalKeys = modalHash.split(MODAL_QUERY_SEPARATOR); + const currentModal = modalKeys.slice(-1)[0]; + actions.setAll([]); + modalKeys.forEach(modalKey => { + actions.set(modalKey, !isDesktop); + }); + actions.set(currentModal, true); + } + } + }; + + useEffect(() => { + // only sync the modal open states with the URL params when initial mount... + syncModalParams(); + }, []); + + useEffect(() => { + if (!queryString?.modal) actions.reset(); + }, [queryString?.modal]); + + // ...or when the user clicks the back button + useEventListener('popstate', () => { + syncModalParams(); + }); + + const hideModal = (options?: THideModalOptions) => { + const modalHash = queryString.modal; + + if (modalHash) { + let modalIds = modalHash.split(MODAL_QUERY_SEPARATOR); + if (options?.shouldHideAllModals) { + isModalOpenScopes.forEach((_, key) => { + actions.set(key, false); + deleteQueryString('modal'); + }); + } else if (options?.shouldHidePreviousModals) { + if (modalIds.length > 1) { + const firstModalId = modalIds.shift(); + modalIds.forEach(modalId => { + actions.set(modalId, false); // Hide each modal except the first + }); + modalIds = [firstModalId ?? '']; // Reset modalIds to only contain the first modal ID + + setQueryString({ + modal: firstModalId, + }); + } else if (modalIds.length === 1) { + setQueryString({ + modal: modalIds[0], + }); + } else { + deleteQueryString('modal'); + } + } else { + const currentModalId = modalIds.pop(); + const previousModalId = modalIds.slice(-1)[0]; + if (previousModalId) { + actions.set(currentModalId, false); + actions.set(previousModalId, true); + } else { + actions.set(currentModalId, false); + } + if (modalIds.length === 0) { + deleteQueryString('modal'); + } else { + setQueryString({ + modal: modalIds.join(MODAL_QUERY_SEPARATOR), + }); + } + } + } + }; + + /** + * Keep the previous modal ids in the URL query strings separated by ',' + * This way, when there is a new modal to be shown, we can track the previous modals from the query string based on the last 2 segments + * + * Example: + * - ModalA is shown, URL becomes /...?modal=ModalA (current modal is ModalA, there is no previous modal) + * - ModalB is shown next, URL becomes /...?modal=ModalA,ModalB (current modal is ModalB, previous modal is ModalA) + * - ModalC is shown next, URL becomes /...?modal=ModalA,ModalB,ModalC (current modal is ModalC, previous modal is ModalB) + * - ModalC is closed, URL becomes becomes /...?modal=modalA,ModalB (current modal is ModalB, previous modal is ModalA) + */ + const showModal = (modalId: string, options?: TShowModalOptions) => { + const modalHash = queryString.modal; + + if (modalHash) { + const modalIds = modalHash.split(MODAL_QUERY_SEPARATOR); + const currentModalId = modalIds.slice(-1)[0]; + + if (currentModalId === modalId) return; + // If shouldStackModals is false, clear the modal stack + if (options?.shouldStackModals === false) { + actions.set(currentModalId, false); + } else { + // set the previous modal open state to false if shouldStackModals is false, otherwise set it to true (default true for mobile) + // set the new modal open state to true + actions.set(currentModalId, options?.shouldStackModals || !isDesktop); + } + actions.set(modalId, true); + // push the state of the new modal to the hash + modalIds.push(modalId); + setQueryString({ + modal: options?.shouldClearPreviousModals ? modalId : modalIds.join(MODAL_QUERY_SEPARATOR), + }); + } else { + actions.set(modalId, true); + setQueryString({ + modal: modalId, + }); + } + }; + + const isModalOpenFor = (modalKey: string) => { + return isModalOpenScopes.get(modalKey) || false; + }; + + return { + hideModal, + isModalOpenFor, + showModal, + }; +} diff --git a/src/hooks/useNavigatorOnline.ts b/src/hooks/useNavigatorOnline.ts new file mode 100644 index 00000000..753e5cf6 --- /dev/null +++ b/src/hooks/useNavigatorOnline.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; + +/** + * Retrieves the current online status of the browser. + * @returns {boolean} The online status of the browser. + */ +const getOnlineStatus = () => + typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true; + +/** + * A custom React hook that tracks the online status of the browser. + * @returns {boolean} The current online status of the browser. + */ +const useNavigatorOnline = () => { + const [status, setStatus] = useState(getOnlineStatus()); + + const setOnline = () => setStatus(true); + const setOffline = () => setStatus(false); + + useEffect(() => { + window.addEventListener('online', setOnline); + window.addEventListener('offline', setOffline); + + return () => { + window.removeEventListener('online', setOnline); + window.removeEventListener('offline', setOffline); + }; + }, []); + + return status; +}; + +export default useNavigatorOnline; diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts new file mode 100644 index 00000000..59432698 --- /dev/null +++ b/src/hooks/useNetworkStatus.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; +import useNavigatorOnline from './useNavigatorOnline'; + +type TStatus = 'blinking' | 'offline' | 'online'; + +const useNetworkStatus = () => { + const [status, setStatus] = useState('online'); + const networkStatus = useNavigatorOnline(); + + // TODO we need socket connection state from api-hooks whenever it finished we can update this part and check + // both navigatorStatus and socket Status + // for now we just check the user network status. + + useEffect(() => { + if (networkStatus) setStatus('online'); + else setStatus('offline'); + }, [networkStatus]); + + return status; +}; + +export default useNetworkStatus; diff --git a/src/hooks/useQueryString.ts b/src/hooks/useQueryString.ts new file mode 100644 index 00000000..570fa3da --- /dev/null +++ b/src/hooks/useQueryString.ts @@ -0,0 +1,104 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +/** + * A hook that leverages React Router v6 to sync URL params with the React component lifecycle. + * You can use this hook to conditionally render tabs, forms, or other screens based on the current URL parameters. + * + * For instance, `/my-profile?tab=Stats`: + * - Calling this hook returns `queryString`, which is an object containing the key `tab` with the value `Stats`. + * - You can then conditionally render the `Stats` tab screen by checking if `queryString.tab === 'Stats'`. + * + * This avoids the need for prop drilling to pass boolean screen setters into child components for switching between different screens/tabs. + * + * @example + * // Call the hook and render the tab based on `?tab=...` + * const { queryString } = useQueryString(); + * + * if (queryString.tab === 'Stats') { + * // Show Stats component + * } + * + * @returns {object} An object containing: + * - `deleteQueryString`: A function to remove a specific query parameter from the URL. + * - `queryString`: An object representing the current URL query parameters. + * - `setQueryString`: A function to add or update query parameters in the URL. + * + * @example + * // Deleting a query parameter + * const { deleteQueryString } = useQueryString(); + * deleteQueryString('tab'); // Removes the 'tab' query parameter from the URL + * + * @example + * // Setting a query parameter + * const { setQueryString } = useQueryString(); + * setQueryString({ tab: 'Payment methods', modal: 'NicknameModal' }); + * // Updates the URL to include `tab=Payment+methods&modal=NicknameModal` + */ + +interface QueryParams { + advertId: string; + formAction: string; + modal: string; + paymentMethodId: string; + tab: string; +} + +function useQueryString() { + const location = useLocation(); + const navigate = useNavigate(); + + // Parse the query string into an object + function getQueryParams(): Partial { + const searchParams = new URLSearchParams(location.search); + const params: Partial = {}; + searchParams.forEach((value, key) => { + params[key as keyof QueryParams] = value; + }); + return params; + } + + // Update the query string in the URL + function setQueryParams(newParams: Partial) { + const searchParams = new URLSearchParams(location.search); + + Object.entries(newParams).forEach(([key, value]) => { + if (value === undefined) { + searchParams.delete(key); + } else { + searchParams.set(key, value); + } + }); + + navigate( + { + search: searchParams.toString(), + }, + { replace: true } + ); + } + + // Function to delete a specific query parameter + function deleteQueryString(key: keyof QueryParams) { + const searchParams = new URLSearchParams(location.search); + searchParams.delete(key); + navigate( + { + search: searchParams.toString(), + }, + { replace: true } + ); + } + + // Function to set multiple query parameters at once + function setQueryString(queryStrings: Partial) { + setQueryParams(queryStrings); + } + + return { + deleteQueryString, + queryString: getQueryParams(), + setQueryString, + }; +} + +export default useQueryString; diff --git a/src/hooks/useSyncedTime.ts b/src/hooks/useSyncedTime.ts new file mode 100644 index 00000000..000a6238 --- /dev/null +++ b/src/hooks/useSyncedTime.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import { useTime } from '@deriv-com/api-hooks'; + +/** + * Custom React hook that syncs with server time and keeps it updated. + * + * This hook fetches the current server time at regular intervals and maintains a local + * time state that is updated every second. The server time is refetched every 30 seconds. + * + * @returns {number} The current server time in seconds since the Unix epoch. + * + * @example + * // Example usage in a functional component + * import React from 'react'; + * import useSyncedTime from './useSyncedTime'; + * + * const ServerTimeDisplay = () => { + * const serverTime = useSyncedTime(); + * + * return
Current Server Time: {new Date(serverTime * 1000).toLocaleString()}
; + * }; + * + * export default ServerTimeDisplay; + */ +const useSyncedTime = () => { + const currentDate = Date.now() / 1000; + const [serverTime, setServerTime] = useState(currentDate); + const { data } = useTime({ refetchInterval: 30000 }); + + useEffect(() => { + let timeInterval: ReturnType; + + if (data) { + setServerTime(data ?? currentDate); + + timeInterval = setInterval(() => { + setServerTime(prev => prev + 1); + }, 1000); + } + + return () => clearInterval(timeInterval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + return serverTime; +}; + +export default useSyncedTime; diff --git a/src/pages/dashboard/load-bot-preview/recent-workspace.tsx b/src/pages/dashboard/load-bot-preview/recent-workspace.tsx index f441b694..8735eee4 100644 --- a/src/pages/dashboard/load-bot-preview/recent-workspace.tsx +++ b/src/pages/dashboard/load-bot-preview/recent-workspace.tsx @@ -7,12 +7,12 @@ import MobileWrapper from '@/components/shared_ui/mobile-wrapper'; import { DBOT_TABS } from '@/constants/bot-contents'; import { timeSince } from '@/external/bot-skeleton'; import { save_types } from '@/external/bot-skeleton/constants/save-type'; +import { useComponentVisibility } from '@/hooks/useComponentVisibility'; import { useStore } from '@/hooks/useStore'; import { waitForDomElement } from '@/utils/dom-observer'; import { Icon } from '@/utils/tmp/dummy'; import { Text } from '@deriv-com/ui'; import { CONTEXT_MENU_MOBILE, MENU_DESKTOP, STRATEGY } from '../../../constants/dashboard'; -import { useComponentVisibility } from '../../../hooks'; import './index.scss'; type TRecentWorkspace = { diff --git a/src/types/index.ts b/src/types/index.ts index 9e87f07e..0420eec2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ export * from './blockly.types'; export * from './dbot.types'; -export * from './root-stores.types'; +export * from './localize.types'; export * from './strategy.types'; export * from './ws.types'; diff --git a/src/types/localize.types.ts b/src/types/localize.types.ts new file mode 100644 index 00000000..8254a7d1 --- /dev/null +++ b/src/types/localize.types.ts @@ -0,0 +1,3 @@ +import { useTranslations } from '@deriv-com/translations'; + +export type TLocalize = ReturnType['localize']; diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 00000000..23f80db2 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,6 @@ +import { URLConstants } from '@deriv-com/utils'; + +export const ACCOUNT_LIMITS = `${URLConstants.derivAppProduction}/account/account-limits`; +export const DERIV_COM = URLConstants.derivComProduction; +export const HELP_CENTRE = `${URLConstants.derivComProduction}/help-centre/`; +export const RESPONSIBLE = `${URLConstants.derivComProduction}/responsible/`; diff --git a/src/utils/languages.tsx b/src/utils/languages.tsx new file mode 100644 index 00000000..bbf76afa --- /dev/null +++ b/src/utils/languages.tsx @@ -0,0 +1,102 @@ +import { + FlagArabLeagueIcon, + FlagFranceIcon, + FlagGermanyIcon, + FlagItalyIcon, + FlagPolandIcon, + FlagPortugalIcon, + FlagRussiaIcon, + FlagSpainIcon, + FlagThailandIcon, + FlagTurkeyIcon, + FlagUnitedKingdomIcon, + FlagVietnamIcon, +} from '@deriv/quill-icons'; + +export const LANGUAGES = [ + { + code: 'EN', + displayName: 'English', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'AR', + displayName: 'العربية', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'ES', + displayName: 'Español', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + + { + code: 'DE', + displayName: 'Deutsch', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'PT', + displayName: 'Português', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'PL', + displayName: 'Polish', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'RU', + displayName: 'Русский', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'FR', + displayName: 'Français', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'IT', + displayName: 'Italiano', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'TH', + displayName: 'ไทย', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'TR', + displayName: 'Türkçe', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, + { + code: 'VI', + displayName: 'Tiếng Việt', + icon: , + placeholderIcon: , + placeholderIconInMobile: , + }, +]; diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 00000000..0af69f53 --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,188 @@ +import moment, { Moment } from 'moment'; +import { TLocalize } from 'types'; + +/** + * Function that converts a numerical epoch value into a Moment instance + */ +export const epochToMoment = (epoch: number) => moment.unix(epoch).utc(); + +/** + * Function that takes a primitive type and converts it into a Moment instance + */ +export const toMoment = (value?: moment.MomentInput): moment.Moment => { + if (!value || !moment(value).isValid()) return moment().utc(); // returns 'now' moment object + if (moment.isMoment(value) && value.isValid() && value.isUTC()) return value; // returns if already a moment object + if (typeof value === 'number') return epochToMoment(value); // returns epochToMoment() if not a date + + return moment.utc(value); +}; + +/** + * return the number of days since the date specified + * @param {String} date the date to calculate number of days since + * @return {Number} an integer of the number of days + */ +export const daysSince = (date: string): number => + toMoment().startOf('day').diff(toMoment(date).startOf('day'), 'days'); + +/** + * The below function is used to format the display the time given in minutes to hours and minutes + * e.g. 90 minutes will be displayed as 1 hour 30 minutes + * @param {number} minutes - The time in minutes (convert to epoch time before passing) + * @returns {string} formatted time string e.g. 1 hour 30 minutes + */ +export const formatTime = (minutes: number, localize: TLocalize) => { + if (!minutes) return ''; + const timeInMinutes = minutes / 60; + const hours = Math.floor(timeInMinutes / 60); + const remainingMinutes = timeInMinutes % 60; + const hoursText = hours === 1 ? localize('hour') : localize('hours'); + const minutesText = remainingMinutes === 1 ? localize('minute') : localize('minutes'); + + if (hours === 0) { + return `${remainingMinutes} ${minutesText}`; + } + + if (remainingMinutes === 0) { + return `${hours} ${hoursText}`; + } + + return `${hours} ${hoursText} ${remainingMinutes} ${minutesText}`; +}; + +/** + * Gets the formatted date string in the format "DD MMM YYYY HH:mm:ss". e.g.: "01 Jan 1970 21:01:11" + * or "MMM DD YYYY HH:mm:ss" for local time. e.g.: "Jan 01 1970 21:01:11" or without seconds if + * hasSeconds is false. e.g.: "01 Jan 1970 21:01" or "Jan 01 1970 21:01". + * + * @param {Date} dateObj - The date object to format. + * @param {boolean} isLocal - Whether to use local time or UTC time. + * @param {boolean} hasSeconds - Whether to include seconds in the time. + * @returns {String} The formatted date string. + */ +export const getFormattedDateString = ( + dateObj: Date, + isLocal = false, + hasSeconds = false, + onlyDate = false +): string => { + const dateString = isLocal ? dateObj.toString().split(' ') : dateObj.toUTCString().split(' '); + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + const [_, day, month, year, time] = dateString; + const times = time.split(':'); + + // Return time in the format "HH:mm:ss". e.g.: "01 Jan 1970 21:01:11" + if (!hasSeconds) { + times.pop(); + } + + if (onlyDate) { + return `${month} ${day} ${year}`; + } + + const timeWithoutSec = times.join(':'); + + // Return in the format "DD MMM YYYY HH:mm". e.g.: "01 Jan 1970 21:01" + return `${day} ${month} ${year}, ${timeWithoutSec}`; +}; + +/** + * Converts the epoch time to milliseconds. + * @param {Number} epoch - The epoch time to convert. + * @returns {Number} The epoch time in milliseconds. + */ +export const convertToMillis = (epoch: number): number => { + const milliseconds = epoch * 1000; + return milliseconds; +}; + +/** + * Converts a number to double digits. + * @param {Number} number - The number to convert. + * @returns {String} The number in double digits. + */ +const toDoubleDigits = (number: number): string => number.toString().padStart(2, '0'); + +/** + * Converts the distance in milliseconds to a timer string in the format "HH:MM:SS". e.g.: "00:00:00" + * @param {Number} distance - The distance in milliseconds. + * @returns {String} The timer string. + */ +export const millisecondsToTimer = (distance: number): string => { + const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((distance % (1000 * 60)) / 1000); + + return `${toDoubleDigits(hours)}:${toDoubleDigits(minutes)}:${toDoubleDigits(seconds)}`; +}; + +/** + * Get the distance to the server time. + * @param {Number} compareTime - The time to compare to the server time. + * @param {Moment} serverTime - The server time. + * @returns {Number} The distance to the server time. + */ +export const getDistanceToServerTime = (compareTime: number, serverTime?: Moment): number => { + const time = moment(compareTime); + const distance = time.diff(serverTime, 'milliseconds'); + return distance; +}; + +/** + * Formats milliseconds into a string according to the specified format. + * @param {Number} miliseconds miliseconds + * @param {String} strFormat formatting using moment e.g - YYYY-MM-DD HH:mm + */ +export const formatMilliseconds = (miliseconds: moment.MomentInput, strFormat: string, isLocalTime = false) => { + if (isLocalTime) { + return moment(miliseconds).format(strFormat); + } + return moment.utc(miliseconds).format(strFormat); +}; + +/** + * Gets the date string after the given number of hours. + * @param {Number} initialEpoch - The initial epoch time. + * @param {Number} hours - The number of hours to add. + * @returns {String} The date string after the given number of hours. + */ +export const getDateAfterHours = (initialEpoch: number, hours: number): string => { + const milliseconds = hours * 60 * 60 * 1000; + const initialDayMilliseconds = convertToMillis(initialEpoch); + const totalMilliseconds = initialDayMilliseconds + milliseconds; + + return getFormattedDateString(new Date(totalMilliseconds)); +}; + +/** + * Converts an epoch timestamp to a formatted UTC string. + * + * @param {number} time - The epoch timestamp in seconds. + * @param {string} format - The desired output format of the date and time. + * This format string follows the conventions used by the moment.js library. + * @returns {string} The formatted date and time string in UTC. + * + * @example + * // Convert epoch timestamp to UTC date string in 'YYYY-MM-DD HH:mm:ss' format + * const formattedDate = epochToUTC(1609459200, 'YYYY-MM-DD HH:mm:ss'); + * console.log(formattedDate); // Output: '2021-01-01 00:00:00' + */ +export const epochToUTC = (time: number, format: string) => moment.unix(time).utc().format(format); + +/** + * Converts an epoch timestamp to a formatted local time string. + * + * @param {number} time - The epoch timestamp in seconds. + * @param {string} format - The desired output format of the date and time. + * This format string follows the conventions used by the moment.js library. + * @returns {string} The formatted date and time string in local time. + * + * @example + * // Convert epoch timestamp to local date string in 'YYYY-MM-DD HH:mm:ss' format + * const formattedDate = epochToLocal(1609459200, 'YYYY-MM-DD HH:mm:ss'); + * console.log(formattedDate); // Output will vary depending on the local timezone, e.g., '2020-12-31 19:00:00' for EST + */ +export const epochToLocal = (time: number, format: string) => moment.unix(time).utc().local().format(format); + +export const DATE_TIME_FORMAT_WITH_GMT = 'YYYY-MM-DD HH:mm:ss [GMT]'; +export const DATE_TIME_FORMAT_WITH_OFFSET = 'YYYY-MM-DD HH:mm:ss Z'; diff --git a/tsconfig.json b/tsconfig.json index 2bf5aa57..aa6fbfcf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "baseUrl": "./src", "paths": { "@/*": ["./*"] - } + }, + "types": ["jest", "@testing-library/jest-dom"] }, "include": ["src"] }